# Phase 2 Part 1 (B1) — Data Layer + Admin Paper Access Audit Service

**Status**: APPROVED (2026-04-14)
**Open question resolutions**: 1 (saveAndFlush confirmed)

---

## 1. Executive Summary

B1 delivers the foundational data layer and compliance-critical audit service for admin manuscript access in the Shodh Sanchayan platform. It introduces six new files — a Flyway migration creating the `admin_paper_access_audit` table, a dedicated `AdminPaperAccessType` enum, a JPA entity, a Spring Data repository, and an audit service (interface and implementation) — and zero modified files. The headline design decision is **fail-closed audit discipline**: if the audit row cannot be written, the admin does not receive the manuscript bytes. This is the inverse of the reviewer pipeline's fail-open discipline (`PaperAccessAuditServiceImpl`, which catches and swallows all exceptions) and is justified by the higher compliance weight of admin access events and the mandatory reason field whose capture is the entire point of the audit mechanism. B2 will build the orchestration service and HTTP controller on top of B1's data layer. The primary risks are: (1) the `REQUIRES_NEW` transaction propagation must be called through a Spring proxy, not via `this`, which is guaranteed by B1's decomposition into a separate bean; (2) the fail-closed behavior means a DB outage blocks admin manuscript access entirely, which is an intentional trade-off for compliance; (3) the `saveAndFlush` pattern surfaces constraint violations synchronously but may have minor performance implications compared to deferred flushing, which is acceptable given the low frequency of admin access events.

---

## 2. Flyway Migration — `admin_paper_access_audit`

### 2.1 Reference Pattern

The reference migration is `V7__paper_access_audit.sql` at path `shodh-sanchayan-api/src/main/resources/db/migration/V7__paper_access_audit.sql`. The highest existing Flyway migration version is **V11** (`V11__add_manuscript_pdf_key.sql`). The naming convention is `V{n}__{description}.sql` — a capital V, the version number (no leading zeros), two underscores, a snake_case description, and the `.sql` extension.

Conventions observed in V7 and other existing migrations:

- **UUID generation**: `uuid_generate_v4()` function call as the column default for UUID primary keys.
- **Timestamp type**: `TIMESTAMPTZ` (PostgreSQL timestamp with timezone) with `DEFAULT NOW()`.
- **Index naming**: `idx_{table_name}_{column_or_purpose}` pattern, e.g., `idx_paper_access_audit_review`.
- **CHECK constraint placement**: inline with the column definition, using the `CHECK (column IN ('VALUE1', 'VALUE2'))` syntax.
- **Foreign key syntax**: inline `REFERENCES {table}(id)` on the column definition, with no explicit `ON DELETE` clause (which defaults to `NO ACTION` in PostgreSQL — functionally equivalent to `RESTRICT` for this purpose; the foreign key prevents deletion of the referenced row).

The new migration file is **`V12__create_admin_paper_access_audit.sql`**.

### 2.2 Schema Requirements — Described in Prose

The `admin_paper_access_audit` table contains the following columns:

**Primary key** — `id`, type UUID, generated at the database level using `uuid_generate_v4()` as the default, exactly matching the V7 migration's pattern.

**Admin user ID** — `admin_user_id`, type UUID, NOT NULL, with a foreign key reference to `users(id)`. No `ON DELETE CASCADE` clause. The foreign key uses the same inline `REFERENCES` syntax as V7. The absence of cascade delete ensures that if an admin account is ever deleted or deactivated, the audit rows of their past accesses remain in the table for compliance investigations. This is a deliberate structural choice: audit records must outlive the entities they reference.

**Paper ID** — `paper_id`, type UUID, NOT NULL, with a foreign key reference to `papers(id)`. No `ON DELETE CASCADE`, for the same reason as above. A deleted paper's audit trail must be preserved.

**Access type** — `access_type`, type `VARCHAR(16)`, NOT NULL, with a CHECK constraint restricting values to the two valid strings (the preview and download type names). The column type and length match V7's `access_type` column exactly: `VARCHAR(16)` with an inline CHECK constraint. The specific string values in the CHECK will correspond to the `AdminPaperAccessType` enum values defined in Section 3.

**Reason** — `reason`, type `TEXT`, NOT NULL, with a DB-level CHECK constraint enforcing a minimum character length of 10. The CHECK uses PostgreSQL's `char_length()` function on the column value. This is `TEXT` rather than `VARCHAR(n)` because reason text can be arbitrarily long — an admin writing a detailed investigation note (explaining a plagiarism inquiry, an author dispute, a regulatory request) should not hit a character limit. The minimum enforcement is what matters (10 characters), not a maximum. The 10-character minimum at the DB level is defense in depth: the service layer in B2 also validates, but the DB CHECK is the final backstop against any code path that bypasses the service layer (direct SQL, migration scripts, database admin tools, future API endpoints that skip validation).

**IP address** — `ip_address`, type `VARCHAR(45)`, NOT NULL. This matches V7 exactly, which uses `VARCHAR(45)` with the comment "IPv6 max length." The 45-character limit accommodates the longest possible IPv6 representation including zone IDs.

**User agent** — `user_agent`, type `VARCHAR(500)`, nullable. This matches V7 exactly, which uses `VARCHAR(500)` with no NOT NULL constraint. The user-agent header is optional (some HTTP clients do not send it), so nullable is correct. Matching V7's type and length ensures consistency across the two audit tables — anyone querying or maintaining them encounters the same column types and can reason about truncation behavior identically.

**Accessed at** — `accessed_at`, type `TIMESTAMPTZ`, NOT NULL, with `DEFAULT NOW()` matching V7's pattern. The default provides a safety net if the application layer fails to set the timestamp, but the normal path is for the service layer to set the value explicitly before persistence (see Section 4.3).

**Columns intentionally absent:**

No `review_id` column. Admin access is paper-scoped, not review-scoped. When an admin investigates a paper — checking a plagiarism report, handling an author appeal, reviewing the manuscript before reassigning a stalled review — they access the paper itself, not a specific review. Requiring the admin to pick a review to access the manuscript would be a UX burden with no compliance benefit. The `paper_access_audit` table has `review_id` because reviewer access is inherently review-scoped (a reviewer accesses a paper through their assigned review), but admin access has no such structural coupling.

**Indexes:**

Three indexes supporting three distinct query patterns, using the naming convention from V7 (`idx_{table_name}_{column_or_purpose}`):

1. **By admin user** — index on `admin_user_id`. Supports the query "what has this admin accessed recently?" This is the primary compliance investigation pattern: an auditor or supervisor wants to see all papers a specific admin has accessed.

2. **By paper** — index on `paper_id`. Supports the query "who has accessed this paper?" This is the secondary investigation pattern: an author disputes whether their paper was accessed without justification, and the investigation needs all access events for that paper.

3. **By access timestamp, descending** — index on `accessed_at DESC`. Supports the query "show me recent activity across all admins." This is the monitoring pattern: an operations team or compliance officer reviews the most recent admin accesses across the system.

A single composite index would not efficiently cover all three patterns. The by-admin query needs `admin_user_id` as the leading column; the by-paper query needs `paper_id` as the leading column; the by-timestamp query needs `accessed_at` as the leading column. Three separate indexes, each with the appropriate leading column, are necessary.

No composite index (analogous to V7's `idx_paper_access_audit_rev_pap_t`) is needed in B1 because the admin audit table has no review-scoping and the three single-column indexes cover the three known query patterns. If a future phase needs a composite index (for example, "all accesses by a specific admin to a specific paper"), it can be added in that phase's migration.

### 2.3 Migration Rollback Approach

Flyway does not auto-rollback migrations. The rollback approach is a manual database operation: drop the new table. The implementation prompt will write the actual DDL command using the same DROP TABLE convention the existing codebase uses for any documented rollback procedures.

**Data loss warning**: If admin manuscript access has been used in production before rollback, dropping the audit table destroys the audit history. Rollback is only safe immediately after deployment, before any production use has accumulated audit rows. For any rollback after production use, a separate "preserve audit data" step is needed: export the table contents to a backup table or archive to cold storage. This is a manual process outside the scope of Phase 2's automated deliverables. The person performing the rollback must assess whether audit rows exist before dropping the table.

---

## 3. New Enum — `AdminPaperAccessType`

### 3.1 Reference Pattern and Placement

The reference enum is `PaperAccessType` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/enums/PaperAccessType.java`. It is a single-line enum declaration in the `com.shodh.sanchayan.enums` package with two values: `PREVIEW` and `DOWNLOAD`.

The new `AdminPaperAccessType` enum lives in the same package (`com.shodh.sanchayan.enums`), with the file name `AdminPaperAccessType.java`. It has two values matching the naming used by `PaperAccessType` — one for inline preview access and one for attachment download access — so that a reader comparing the two enums sees the parallel structure immediately.

### 3.2 Why Separate from `PaperAccessType`

The locked decision on admin access audit specifies a separate enum. The justification is fourfold:

First, the two enums serve different contexts and audit to different tables. `PaperAccessType` is used exclusively by `PaperAccessAuditService` and the `paper_access_audit` table (reviewer access). `AdminPaperAccessType` is used exclusively by `AdminPaperAccessAuditService` and the `admin_paper_access_audit` table. Entangling them creates coupling between two features that should remain independently evolvable.

Second, Phase 3 or Phase 4 may introduce new admin access types — such as an override access type (admin bypassing the reviewer pipeline to directly annotate) or a reassignment-related access type — that should not pollute the reviewer pipeline's enum. Keeping the enums separate means extending the admin enum has zero impact on the reviewer pipeline.

Third, type safety at the Java level prevents accidentally passing a reviewer enum value to an admin audit method. If both services accepted the same `PaperAccessType`, a caller could pass the wrong type and audit to the wrong table without a compile error. With separate enums, the compiler catches the mismatch.

Fourth, the SQL CHECK constraints in the two migration tables reference specific string values. While both CHECK constraints happen to contain the same strings today, they are independent constraints on independent columns. If the admin enum gains new values in a later phase, only the admin migration needs a CHECK constraint update — the reviewer table's constraint is untouched.

This is confirmed as the right call. Reusing `PaperAccessType` would be slightly fewer files but would create a coupling vector that is not worth the savings.

---

## 4. JPA Entity — `AdminPaperAccessAudit`

### 4.1 Reference Pattern to Match

The reference entity is `PaperAccessAudit` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/entity/PaperAccessAudit.java`. Its conventions, which B1 matches (with documented deviations):

- **Package**: `com.shodh.sanchayan.entity`
- **Lombok annotations**: `@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder` at the class level. The entity does NOT extend `BaseEntity` (unlike `Review.java` which does).
- **JPA annotations**: `@Entity` and `@Table(name = "...")` at the class level.
- **ID generation**: `@Id` with `@GeneratedValue(strategy = GenerationType.UUID)` — Jakarta Persistence's UUID generation strategy. No manual UUID creation.
- **Foreign key mapping**: `@ManyToOne(fetch = FetchType.LAZY)` with `@JoinColumn(name = "...", nullable = false)` referencing entity types (`Review`, `User`, `Paper`), not raw UUID fields.
- **Enum mapping**: `@Enumerated(EnumType.STRING)` with `@Column(name = "...", nullable = false, length = 16)`.
- **Timestamp handling**: The `accessedAt` field is typed as `Instant`. A `@PrePersist` callback sets it to `Instant.now()` if null at persist time. No explicit `@Column(columnDefinition = "TIMESTAMPTZ")` — Hibernate's default mapping for `Instant` handles the PostgreSQL type.
- **Column name mapping**: Explicit `@Column(name = "...")` on every field, including where the Java name differs from the SQL name (e.g., `ipAddress` → `ip_address`) and even where it could be inferred (e.g., `userAgent` → `user_agent`). B1 follows this pattern of being explicit on every field.

The new `AdminPaperAccessAudit` entity matches these conventions for all of the above, with the specific field mappings below and deviations documented in Section 4.3.

### 4.2 Field Mappings

**Primary key ID** — field name `id`, type `UUID`, annotated with `@Id` and `@GeneratedValue(strategy = GenerationType.UUID)`, mapping to the `id` SQL column. Identical to the reference pattern.

**Admin user** — field name `adminUser`, type `User`, annotated with `@ManyToOne(fetch = FetchType.LAZY)` and `@JoinColumn(name = "admin_user_id", nullable = false)`. This follows the reference pattern's convention of using `@ManyToOne` relationships for foreign keys rather than raw UUID fields. The audit service implementation uses `entityManager.getReference(User.class, adminUserId)` to set this field without triggering a SELECT, matching the reference pattern in `PaperAccessAuditServiceImpl`.

**Paper** — field name `paper`, type `Paper`, annotated with `@ManyToOne(fetch = FetchType.LAZY)` and `@JoinColumn(name = "paper_id", nullable = false)`. Same pattern as the admin user field.

**Access type** — field name `accessType`, type `AdminPaperAccessType`, annotated with `@Enumerated(EnumType.STRING)` and `@Column(name = "access_type", nullable = false, length = 16)`. Matches the reference pattern's enum mapping exactly, substituting the new `AdminPaperAccessType` enum for `PaperAccessType`.

**Reason** — field name `reason`, type `String`, annotated with `@Column(name = "reason", nullable = false, columnDefinition = "TEXT")`. The `columnDefinition = "TEXT"` is necessary because Hibernate's default mapping for `String` is `VARCHAR(255)`, which would conflict with the unbounded `TEXT` type in the migration. The reference entity has no equivalent field, so there is no reference pattern to follow here — this is new to B1.

**IP address** — field name `ipAddress`, type `String`, annotated with `@Column(name = "ip_address", nullable = false, length = 45)`. Matches the reference entity's pattern exactly.

**User agent** — field name `userAgent`, type `String`, annotated with `@Column(name = "user_agent", length = 500)`. Nullable (no `nullable = false`). Matches the reference entity's pattern exactly.

**Accessed at** — field name `accessedAt`, type `Instant`, annotated with `@Column(name = "accessed_at", nullable = false)`. The entity includes a `@PrePersist` callback matching the reference pattern: if `accessedAt` is null at persist time, it is set to `Instant.now()`. See Section 4.3 for the full timestamp assignment discussion.

### 4.3 Entity Design Decisions That May Differ from Reference

**Decision 1: `@ManyToOne` vs raw UUID for foreign keys.**

The reference entity (`PaperAccessAudit`) uses `@ManyToOne(fetch = FetchType.LAZY)` with `@JoinColumn` for all three foreign keys (`review`, `reviewer`, `paper`). The service implementation uses `entityManager.getReference()` to set these relationships without triggering a SELECT — a JPA proxy reference is sufficient for the INSERT.

B1 follows the same pattern: `@ManyToOne(fetch = FetchType.LAZY)` with `@JoinColumn` for both `adminUser` (maps to `admin_user_id`) and `paper` (maps to `paper_id`). The audit service implementation will use `entityManager.getReference(User.class, adminUserId)` and `entityManager.getReference(Paper.class, paperId)` to set the relationship fields without extra SELECT queries. This matches the reference pattern exactly and avoids introducing a second mapping convention within the audit infrastructure.

The alternative — raw UUID fields (`private UUID adminUserId`) — would avoid the `EntityManager` dependency but would diverge from the reference pattern and lose JPA's foreign key validation at the ORM level. Consistency with the existing audit entity is more valuable than the minor simplification.

**Decision 2: Timestamp assignment strategy.**

The reference entity uses a `@PrePersist` callback: if `accessedAt` is null, set it to `Instant.now()`. The DB column also has `DEFAULT NOW()`. This is a two-layer safety net.

B1 follows the same pattern. The service layer will set `accessedAt` explicitly before persistence (providing a precise timestamp and simplifying test assertions), but the `@PrePersist` callback provides a safety net if the service layer omits it, and the DB default provides a further safety net if both the service and the callback miss it. This triple-layer approach matches the reference pattern.

**Decision 3: Enum storage strategy.**

B1 stores the enum as its string name via `@Enumerated(EnumType.STRING)`, matching the reference pattern. This is essential because the SQL CHECK constraint references string values. Ordinal storage (`EnumType.ORDINAL`) would silently break if the enum order changes — a value reordering would cause the wrong string to be stored, violating the CHECK constraint or, worse, passing the CHECK but recording the wrong access type.

**No additional divergences from the reference pattern were identified.**

---

## 5. Spring Data Repository — `AdminPaperAccessAuditRepository`

### 5.1 Reference Pattern, Placement, Interface

The reference repository is `PaperAccessAuditRepository` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/repository/PaperAccessAuditRepository.java`. Its conventions:

- **Package**: `com.shodh.sanchayan.repository`
- **Annotations**: `@Repository` at the interface level
- **Extends**: `JpaRepository<PaperAccessAudit, UUID>`
- **Custom methods**: none — the comment explicitly states "Read methods intentionally omitted in Phase 2B-i"

The new `AdminPaperAccessAuditRepository` interface lives in the same package (`com.shodh.sanchayan.repository`), is annotated with `@Repository`, and extends `JpaRepository<AdminPaperAccessAudit, UUID>`. The file name is `AdminPaperAccessAuditRepository.java`.

### 5.2 Why No Custom Methods Now

B1 introduces no custom query methods on the repository. The only operation the audit service needs is the inherited `saveAndFlush` method from `JpaRepository`. Adding query methods (such as "find all audit rows for a given admin user, sorted by timestamp descending") before they have a consumer would be speculative. The admin activity page or compliance dashboard that would consume those queries is not in Phase 2's scope. When that page is built in a later phase, its spec will define the query methods based on the actual UI requirements. Until then, the repository is a minimal persistence interface with only inherited methods.

---

## 6. `AdminPaperAccessAuditService` — The Fail-Closed Core

### 6.1 Service Decomposition — Interface and Impl

Two files:

- **Interface**: `AdminPaperAccessAuditService` in package `com.shodh.sanchayan.service`, at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/service/AdminPaperAccessAuditService.java`. The reference for package location is `PaperAccessAuditService` at the same path level.

- **Implementation**: `AdminPaperAccessAuditServiceImpl` in package `com.shodh.sanchayan.service.impl`, at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/service/impl/AdminPaperAccessAuditServiceImpl.java`. The reference for package location and conventions is `PaperAccessAuditServiceImpl` at the same path level.

The interface exposes a single method with the following contract:

- **Parameters**: admin user UUID, paper UUID, `AdminPaperAccessType` enum value, reason string, IP address string, user-agent string.
- **Returns**: void.
- **Semantic contract**: if the call returns normally, the audit row is durably persisted in its own committed transaction. If the call throws any exception, the audit row was NOT persisted and the caller must NOT proceed with any dependent operation.

The interface Javadoc must explicitly document the fail-closed contract. The Javadoc states, in substance: "Fail-closed — if the audit write fails, the exception propagates to the caller. The caller must interpret any thrown exception as a failed access and must NOT return manuscript bytes to the admin. This is the inverse of the reviewer audit service's fail-open contract documented in PaperAccessAuditService."

The Javadoc also documents the `REQUIRES_NEW` transaction propagation so that any future caller understands that the audit write commits independently of the caller's transaction.

### 6.2 The Fail-Closed Discipline — Full Justification

**What fail-closed means in concrete terms:**

If the audit row cannot be written to the database for any reason — connection loss, constraint violation (including the 10-character reason CHECK), deadlock, transaction timeout, generic `DataAccessException` — the audit service's `logAccess` method throws. The calling service (`AdminPaperAccessService`, designed in B2) does NOT catch and swallow the exception. The admin's HTTP request fails with an internal server error. The admin does not see the manuscript bytes. The admin's browser receives nothing. The audit log contains nothing. The state is consistent: the event did not happen, and no unaudited access occurred.

**Why fail-closed for admin audit, when the reviewer audit is fail-open:**

1. **Compliance weight.** Admin audit is the primary compliance record for editorial-staff access to manuscripts. An admin — an editorial board member or journal administrator — accessing an author's unpublished manuscript is a privileged action that must be defensible in a later audit, dispute, or security review. A missing audit row for an admin access means the access cannot be defended. By contrast, reviewer audit is a secondary record. The primary record of reviewer activity is the review submission itself (the scores, the recommendation, the comments). A missing audit row for a reviewer preview is a soft tracking gap — it does not undermine the core compliance record, which is the review.

2. **Frequency asymmetry.** Admin access to manuscripts is rare and high-stakes. An admin downloads a paper as an exceptional event: investigating a plagiarism report, handling an author appeal, reassigning a stalled review, preparing for an editorial board meeting. The cost of fail-closed user experience (an admin sees a 500 error and retries after the DB issue is resolved) is acceptable because the admin is already treating this as a deliberate, justification-worthy event. A reviewer, by contrast, previews a paper many times a day while writing a review. The cost of fail-closed for reviewers would be unacceptable because reviewers hit the preview endpoint constantly during normal workflow, and any transient DB hiccup would block them from doing their core work.

3. **Mandatory reason coupling.** The admin audit row contains the `reason` field — the admin's free-text justification for accessing the paper. The reason is the only record of "why did the admin look at this paper on this date." It is the entire point of the mandatory reason mechanism introduced in Phase 2. Without the audit row, there is no reason captured, and the mandatory-reason UX (the modal asking "why are you accessing this paper?") becomes theater — the admin types a reason, the UI sends it, but it vanishes into the void. Fail-open would create a silent failure mode where admins access papers without their justification being recorded, which defeats the compliance purpose of the entire feature.

4. **Defense in depth for the 10-character minimum.** The `reason` column has a DB-level CHECK constraint for minimum length. If the service-layer validation (in B2) has a bug — incorrect trim handling, whitespace-only strings passing validation, Unicode normalization issues causing length miscounts — the DB constraint catches it and raises a `DataIntegrityViolationException`. Fail-closed ensures this exception blocks the request, surfacing the bug immediately. Fail-open would hide the bug by silently allowing the access without an audit row, and the bug might not be discovered until a compliance review months later reveals missing or malformed audit rows.

5. **Precedent reversal is intentional.** The reviewer pipeline's fail-open discipline (`PaperAccessAuditServiceImpl`, line 45-51: "Intentionally swallowed. See Javadoc — fail-open contract.") is correct for its context. That Javadoc explicitly calls out the trade-off: "an incomplete audit trail is preferable to blocking a legitimate reviewer from accessing an assigned paper." The admin context inverts this trade-off: an incomplete admin audit trail is NOT preferable to blocking an admin access, because admin access is the privileged event the audit trail exists to track. Copying the fail-open pattern from the reviewer service into the admin audit service would be a correctness bug, not a consistency improvement. The two services serve different compliance contexts with different risk profiles, and the different failure disciplines reflect that difference.

**What fail-closed does NOT mean:**

It does not mean "retry until success." If the DB is down, the request fails immediately with a clear error message. There is no retry logic, no exponential backoff, no circuit breaker. Fail-closed is about propagating the failure to the caller so that the access is blocked, not about masking the failure with automatic recovery. An operations team monitoring admin audit errors should see the failure immediately and investigate the DB issue, not see a self-healing retry that hides the underlying problem.

### 6.3 Transaction Propagation — The `REQUIRES_NEW` Decision

The audit service's `logAccess` method writes a row, so it must be transactional. The question is whether it should run in the caller's transaction or in its own independent transaction.

**Option A — Same transaction (Spring's default `REQUIRED` propagation):**

The audit write runs inside whatever transaction the caller opened. If the audit fails, the caller's transaction rolls back naturally — fail-closed semantics are preserved. If the caller later fails (for example, the manuscript fetch fails after the audit write), the audit row is rolled back along with the caller's work. This means a failed access attempt would leave no audit row, even though the audit write itself succeeded at the time.

**Option B — `REQUIRES_NEW`:**

The audit write runs in its own nested transaction. The caller's outer transaction is suspended while the audit write executes, then resumed. If the audit write succeeds and commits, the audit row is durable regardless of what happens to the caller afterward. If the audit write fails, the exception propagates out of the nested transaction into the caller, which is the fail-closed behavior.

**Decision: Option B — `REQUIRES_NEW`.**

Three reasons:

First, **compliance durability**. The audit row should be committed as soon as the `logAccess` method returns normally. In the admin manuscript access flow (designed in B2), the audit write fires before the manuscript bytes are fetched and returned to the admin. If the HTTP connection drops while streaming bytes, or if the S3 fetch fails after the audit is written, the audit row should remain committed because the access decision was made and approved — the admin was authorized, provided a reason, and the system intended to serve the bytes. The fact that the bytes didn't fully arrive is a delivery failure, not an access-decision failure. Option A would roll back the audit row in that scenario, which means a partial access event (the admin may have seen some bytes before the connection dropped) would have no audit record.

Second, **isolation of concerns**. The audit write is a compliance decision that is conceptually independent of the access operation. Using `REQUIRES_NEW` expresses this separation at the transaction boundary: the audit commits on its own merits, regardless of the caller's fate. This makes the system's behavior easier to reason about during compliance reviews and incident investigations.

Third, **Spring proxy safety**. The `REQUIRES_NEW` propagation only works if the call goes through a Spring AOP proxy. In B1's design, the caller (B2's `AdminPaperAccessService`) is a separate Spring bean that injects `AdminPaperAccessAuditService` via constructor injection and calls `logAccess` through the injected reference. This call goes through Spring's transactional proxy. The audit service is never called via `this` from within the same class. This is the correct structure, and it avoids the Spring self-invocation trap that Phase 1 surfaced when extracting `ReviewStatusTransitionService`.

**Counter-argument acknowledged:**

A reader might argue that Option A is simpler and that the failure mode where an audit row is committed but the response fails is rare enough not to matter. The counter-argument is: rarity is not the same as irrelevance. The entire point of a compliance audit table is to capture events that may be reviewed months or years later, and a single missing audit row — from the one time the S3 connection dropped mid-stream — could be the row a compliance investigator needs. The marginal complexity of `REQUIRES_NEW` (one annotation) is small relative to the compliance assurance it provides.

**Implementation consequence:**

The audit service implementation applies Spring's `@Transactional` annotation with `REQUIRES_NEW` propagation at the method level on `logAccess`, not at the class level. This ensures that if the implementation class ever gains additional methods in a future phase, those methods get their own transaction configuration rather than inheriting the audit method's `REQUIRES_NEW`.

The audit service must be a separate Spring bean from any caller. Self-invocation via `this.logAccess(...)` from within the same class would bypass the Spring AOP proxy and silently ignore the `REQUIRES_NEW` annotation. B1's decomposition — the audit service as a dedicated `@Service` bean, injected into B2's access service via constructor injection — avoids this trap by construction.

### 6.4 Implementation Pattern — Described in Prose

The implementation class follows the conventions of `PaperAccessAuditServiceImpl` (`shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/service/impl/PaperAccessAuditServiceImpl.java`): annotated with `@Service`, `@RequiredArgsConstructor`, and `@Slf4j` at the class level. Constructor-injected dependencies are the `AdminPaperAccessAuditRepository` and the `EntityManager` (for `getReference()` calls that avoid extra SELECT queries, matching the reference pattern).

The `logAccess` method's implementation follows this sequence:

**Entity construction**: The method constructs an `AdminPaperAccessAudit` entity using the Lombok `@Builder` pattern. The `adminUser` and `paper` relationship fields are set using `entityManager.getReference(User.class, adminUserId)` and `entityManager.getReference(Paper.class, paperId)` respectively, matching the reference pattern in `PaperAccessAuditServiceImpl` (lines 37-39). The `accessType`, `reason`, `ipAddress`, and `userAgent` fields are set from the method parameters. The `accessedAt` field is set to `Instant.now()` at construction time.

**User-agent truncation**: Before setting the `userAgent` field on the entity, the method truncates the string to 500 characters (matching the column's `VARCHAR(500)` limit) using the same `truncate` helper pattern as the reference implementation (lines 54-57). If the user-agent is null, it remains null (not an empty string, not a placeholder). This is defense in depth against any HTTP-layer helper failing to truncate — the service layer guarantees the value fits.

**Persistence call**: The entity is saved via the repository's `saveAndFlush` method, NOT the plain `save` method. `saveAndFlush` forces the SQL INSERT to execute synchronously during the method call. If there is a constraint violation — the DB-level reason length CHECK fails, or a foreign key reference is invalid — the `DataIntegrityViolationException` is thrown immediately, during the `logAccess` call, before the method returns. With the plain `save` method, the INSERT might be deferred until transaction commit (Hibernate's write-behind optimization), causing the exception to surface later in a different call stack. For fail-closed semantics, the failure must be synchronous and attributable to the audit write.

**Divergence from reference pattern — `saveAndFlush` vs `save` (RESOLVED)**: The reference implementation (`PaperAccessAuditServiceImpl`, line 44) uses the plain `save` method. B1 diverges by using `saveAndFlush`. This divergence is justified by the different failure discipline. The reference implementation is fail-open: deferred constraint violations are caught by its outer try/catch regardless of when they surface, so `save` is sufficient. B1 is fail-closed: the exception must surface at the `logAccess` call site so the caller knows immediately that the audit write failed and can abort the access. If `save` were used instead, the constraint violation exception would surface at transaction commit time rather than at the `logAccess` call site, producing confusing stack traces that don't attribute the failure to the audit write. The performance cost of the extra flush is negligible given the low frequency of admin access events.

**No try/catch**: The method does NOT wrap the `saveAndFlush` call in a try/catch block. Any exception from the persistence call propagates directly to the caller. This is the core fail-closed property. The reference implementation (`PaperAccessAuditServiceImpl`) wraps its `save` call in a try/catch and swallows exceptions (line 45-51) — that is the fail-open pattern. B1's implementation deliberately omits this wrapping. The method either returns normally (audit row committed) or throws (audit row not committed, caller must abort).

**Logging**: The method logs an INFO line on successful save, described in Section 6.5. There is no WARN or ERROR logging within the method because there is no failure path that the method handles — all failures propagate as exceptions.

### 6.5 Observability

The audit service produces a single log line on success:

**INFO on successful write**: Contains the admin user ID, paper ID, access type, and the reason text. The reason is intentionally included in the log — it is editorial metadata and part of the audit record, and logging it provides operational correlation during investigations without requiring a database query. The IP address and user-agent are NOT included in the INFO log because they are operational noise that is already persisted in the audit table and available for investigation if needed.

**No WARN logs**: There is no "soft failure" path in this service. Everything either succeeds (INFO log) or throws (no log from within the audit service).

**No ERROR logs from within `logAccess`**: The exception propagates to the caller, which is responsible for logging the failure with its own context. B2's access service will produce an ERROR log that includes the admin's full request context (paper ID, admin ID, reason, HTTP method, endpoint). That log is more useful for operations than a log from deep inside the audit service, which would lack the request context.

**Two logging rules:**

The method logs the reason field. This is intentional — the reason is not sensitive content, it is the admin's stated justification for a privileged action, and it belongs in the operational log for correlation purposes.

The method never logs manuscript bytes, storage keys, paper titles, paper content, or any other paper data. The audit service handles metadata about the access event only, not the paper content itself.

### 6.6 Test Strategy for the Audit Service

Test files follow the project's existing convention. No audit-related test files currently exist in `src/test/` (confirmed during discovery), so B1 establishes the convention. Test files go in the same package structure under `src/test/java/com/shodh/sanchayan/service/impl/` for implementation tests.

**Unit test 1 — persists audit row with correct fields**: Given valid inputs (admin UUID, paper UUID, access type, reason of adequate length, IP address, user-agent), mock the repository and entity manager. Verify that the repository's `saveAndFlush` method is called exactly once with an entity that has all fields set correctly, including a timestamp close to `Instant.now()` at the time of the call (allow a small delta, e.g., 1 second, to account for test execution time). Verify the `adminUser` and `paper` fields are set via `getReference` (verify the entity manager's `getReference` calls with the correct class and UUID arguments).

**Unit test 2 — truncates long user-agent**: Given a user-agent string longer than 500 characters, verify the entity passed to `saveAndFlush` has a `userAgent` value truncated to exactly 500 characters. The truncation should be a simple substring, not a smart truncation with ellipsis.

**Unit test 3 — null user-agent**: Given a null user-agent, verify the entity is persisted with null in the `userAgent` field. Not an empty string, not a placeholder string, not the literal "null".

**Unit test 4 — data access exception propagates (fail-closed verification)**: Mock the repository's `saveAndFlush` to throw a `DataIntegrityViolationException` (simulating a constraint violation). Verify the exception propagates out of the `logAccess` method without being caught, wrapped, or transformed. This is the most important unit test — it verifies the fail-closed contract.

**Unit test 5 — generic runtime exception propagates**: Mock the repository's `saveAndFlush` to throw a generic `RuntimeException` (simulating a transient DB error). Verify the exception propagates. This confirms that fail-closed is not limited to specific exception types.

**Integration test 1 — persists to database**: Using a real test database (Spring's `@DataJpaTest` or equivalent with PostgreSQL test container), call the audit service with valid inputs. Query the `admin_paper_access_audit` table and verify a row exists with the expected column values.

**Integration test 2 — CHECK constraint enforcement**: Using a real test database, call the audit service with a reason that is exactly 9 characters (one below the minimum). Verify the DB CHECK constraint throws and the exception propagates out of the service method. This tests the defense-in-depth layer — the DB constraint catches a reason that bypassed service-layer validation.

**Integration test 3 — `REQUIRES_NEW` transaction isolation**: This test verifies that the audit row survives a rollback of the outer transaction. The setup: begin an outer transaction, call the audit service (which commits in its own `REQUIRES_NEW` transaction), then roll back the outer transaction. Assert that the audit row still exists in the database. **This test may be deferrable to B2** because B1's audit service does not have a real caller yet — the outer transaction context is artificial in B1's test suite. If the test is difficult to write correctly in isolation, defer it to B2 once the real caller (`AdminPaperAccessService`) exists and provides a natural outer transaction. Flag this test as "implement in B1 if straightforward, defer to B2 if the test harness is awkward."

---

## 7. Cross-Cutting Concerns

### 7.1 Observability Summary

B1's audit service produces one INFO log line per successful write (admin user ID, paper ID, access type, reason). No new metrics, no new alerts, no new dashboards. An operations team should monitor for fail-closed failures in production. These will surface as HTTP 500 responses from admin endpoints (implemented in B2), accompanied by an ERROR log from B2's access service (not from B1's audit service, which is silent on failure because the exception propagates). The monitoring pattern is: alert on elevated 500 rates from the admin manuscript access endpoint.

### 7.2 Error Handling Summary

B1 introduces no new HTTP error codes — those are defined in B2's controller spec. B1's audit service throws Spring and JPA native exceptions (`DataIntegrityViolationException`, `DataAccessException`, and their subtypes). These exceptions propagate through B2's access service to the controller, where the `GlobalExceptionHandler` (`shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/exception/GlobalExceptionHandler.java`) handles them. The existing `RuntimeException` catch-all handler (line 103-108) translates unhandled runtime exceptions to HTTP 500 with the message "An unexpected error occurred." B2 may introduce a more specific handler with a dedicated error code for audit write failures — that decision belongs to B2's spec, not B1's.

B1's contribution to error handling is ensuring that these exceptions are allowed to propagate without being caught anywhere in the audit service layer. There is no try/catch in the `logAccess` method. There is no catch at the class level. The exception transparency is the fail-closed contract.

### 7.3 Security Review Checklist for B1

A reviewer verifying B1's implementation should check:

1. The `reason` column in the migration has a DB-level CHECK constraint enforcing `char_length(reason) >= 10`. This is the defense-in-depth backstop for the mandatory reason requirement.

2. The `admin_paper_access_audit` table has foreign keys to `users` and `papers` WITHOUT any `ON DELETE CASCADE` clause. The foreign keys use the default `NO ACTION` behavior, which prevents deletion of referenced rows while audit rows exist. Audit rows must survive entity deletions.

3. The audit service's `logAccess` method uses `saveAndFlush` (not `save`) to surface constraint violations synchronously during the method call.

4. The audit service's `logAccess` method is annotated with `@Transactional(propagation = Propagation.REQUIRES_NEW)` at the method level, not at the class level.

5. The audit service's `logAccess` method does NOT contain any try/catch around the persistence call. Fail-closed means exceptions propagate unmodified.

6. The audit service is a separate `@Service` bean from any future caller. Verify by checking that B2's caller (when implemented) injects `AdminPaperAccessAuditService` via constructor injection (`@RequiredArgsConstructor` with a `final` field), not via `this` or a private method.

7. The `AdminPaperAccessType` enum is a separate class from `PaperAccessType`. Verify no shared imports between the admin audit service and the reviewer audit service that would create coupling.

8. The `AdminPaperAccessAudit` entity class follows the conventions of `PaperAccessAudit` — compare annotation lists, field mapping patterns, Lombok annotations, and `@PrePersist` callback between the two entity files.

9. The migration file is `V12__create_admin_paper_access_audit.sql` — verify it comes after the highest existing version (V11) and uses the correct naming convention.

---

## 8. Unexpected Prerequisites Discovered During B1 Specification

**One finding:**

The existing `PaperAccessAuditServiceImpl` uses `entityManager.getReference()` to create JPA proxy references for foreign key fields without triggering SELECT queries (lines 37-39). B1's audit service needs the same `EntityManager` dependency for the same purpose. This is not an unexpected prerequisite in the strict sense — it is a convention established by the reference implementation — but it is worth calling out explicitly because the `EntityManager` injection is not obvious from reading the repository interface alone. The implementation prompt must inject `EntityManager` in addition to the repository, matching the reference pattern.

No other unexpected prerequisites surfaced during B1 specification. No missing utility classes, no Spring configuration changes, no architectural concerns about `REQUIRES_NEW` interaction with the global exception handler (the exception propagates through the normal Spring MVC chain and is caught by `GlobalExceptionHandler`'s `RuntimeException` handler), and no proxy or transactional-boundary concerns beyond what is already documented in Section 6.3.

---

## 9. B1 Rollback Plan

### 9.1 Code Rollback

Rollback of B1's code changes involves reverting the commits that introduced the six new files: the migration, the enum, the entity, the repository, the service interface, and the service implementation. Then rebuild and redeploy.

### 9.2 Database Rollback

Drop the `admin_paper_access_audit` table using a manual SQL command executed against the database. The implementation prompt produces the actual DROP statement. The three indexes are dropped automatically when the table is dropped (PostgreSQL drops indexes owned by a table when the table is dropped).

### 9.3 Data Loss Consideration

If B1 was deployed and any code has been writing to the admin audit table, dropping the table destroys the audit history. However, B1 does not include the caller that would write to this table — the caller is in B2. The risk window for data loss is only open if B1 is deployed to production before B2 is merged AND some other code path (direct SQL, a test script, an admin tool) has been writing to the table independently.

B1 can safely be deployed to production standalone because the new table is harmless in isolation — nothing reads it, nothing writes to it, until B2 adds the caller. The table sitting empty is a zero-impact state. Alternatively, hold B1 in a non-production environment until B2 is ready and deploy both together. Either approach is safe.

---

## 10. Implementation Sequence Within B1

The natural order is data-layer-up:

1. **Flyway migration** — creates the table. Can be tested independently with a migration-only deployment (run the application, Flyway applies the migration, verify the table exists with the correct schema). This is the foundation that everything else depends on.

2. **Enum class** — `AdminPaperAccessType`. Has no dependencies on any other B1 file. Can be implemented in parallel with step 1 if desired, since it doesn't touch the database.

3. **Entity class** — `AdminPaperAccessAudit`. Requires the migration to be applied (so the table exists for JPA validation) and the enum to exist (for the `accessType` field type). Depends on steps 1 and 2.

4. **Repository interface** — `AdminPaperAccessAuditRepository`. Requires the entity (for the generic type parameter). Depends on step 3.

5. **Audit service interface** — `AdminPaperAccessAuditService`. Requires the enum (for the parameter type). Depends on step 2 but is independent of steps 3 and 4. Could be implemented in parallel with the entity and repository if desired.

6. **Audit service implementation** — `AdminPaperAccessAuditServiceImpl`. Requires the repository (for injection), the entity (for construction), the interface (for implementation), and the `EntityManager` (from Spring's JPA auto-configuration). Depends on steps 3, 4, and 5. This is where the real design work happens: the `REQUIRES_NEW` annotation, the `saveAndFlush` call, the fail-closed discipline (no try/catch), and the logging format.

Steps 1 and 2 are independent and can be done in parallel. Steps 3-6 are sequential. The entire sequence forms a single commit since all six files are meaningless without each other.

---

## 11. Open Design Questions for Caller Review

**Question 1 — `saveAndFlush` vs `save` (RESOLVED):**

Use `saveAndFlush` as recommended. The divergence from the reference pattern is justified by the different failure discipline. Fail-closed needs synchronous failure detection at the `logAccess` call site, and plain `save` would defer the constraint violation exception to transaction commit time, producing confusing stack traces that don't attribute the failure to the audit write. The reference pattern uses `save` because it is fail-open and tolerates deferred failures, which is correct for its context. The divergence and reasoning are documented in Section 6.4 so future maintainers understand why B1's pattern differs from `PaperAccessAuditServiceImpl`.

**Question 2 — test infrastructure:**

This is a verification item for implementation, not a design decision. If `@DataJpaTest` infrastructure exists, run the integration tests. If it doesn't, ship B1 with unit tests only and defer integration tests to B2 or a separate infrastructure task.

No other open design questions surfaced during B1 specification.

---

## 12. Summary of B1 Deliverables

**New files (6):**

- `V12__create_admin_paper_access_audit.sql` — Flyway migration creating the `admin_paper_access_audit` table with all columns, constraints, and indexes
- `AdminPaperAccessType.java` — enum in `com.shodh.sanchayan.enums` with two values (preview and download)
- `AdminPaperAccessAudit.java` — JPA entity in `com.shodh.sanchayan.entity` mapping to the new table
- `AdminPaperAccessAuditRepository.java` — Spring Data repository in `com.shodh.sanchayan.repository` with no custom methods
- `AdminPaperAccessAuditService.java` — service interface in `com.shodh.sanchayan.service` with fail-closed Javadoc contract
- `AdminPaperAccessAuditServiceImpl.java` — service implementation in `com.shodh.sanchayan.service.impl` with `REQUIRES_NEW` propagation, `saveAndFlush`, and no try/catch

**Modified files: 0.** B1 does not modify any existing files.

**New tables: 1** (`admin_paper_access_audit`)

**New endpoints: 0** (endpoints are in B2)

**Total files touched: 6** (all new)

**Explicitly out of scope for B1 (deferred to B2):**

- `AdminPaperAccessService` (orchestration layer that calls the audit service, fetches manuscript bytes, and coordinates the fail-closed flow)
- `AdminPaperAccessController` (HTTP endpoints for admin manuscript preview and download)
- Auth rule changes in `SecurityConfig.java` and `ReviewerPaperController.java`
- Helper extraction refactor for `clientIp` and `userAgent` utilities
- New exception class `InvalidReasonException`
- End-to-end test strategy for the full admin manuscript access flow
