# Phase 2 Part 2a (B2a) — Admin Access Service + Reason Exception + Helper Extraction

**Status**: APPROVED (2026-04-14)
**Overrides applied**: 1 (content type detection extraction)
**Open question confirmations**: 1 (observability — no try/catch)

---

## 1. Executive Summary

B2a delivers the orchestration layer that connects B1's fail-closed audit service to the manuscript byte-fetch pipeline for admin access. It introduces five new files — the `AdminPaperAccessService` interface and implementation (the coordination layer that validates the reason, writes the audit row via B1, and fetches the raw manuscript bytes), the `InvalidReasonException` class (thrown when server-side reason validation fails), the `HttpRequestUtils` utility class (extracting the duplicated `clientIp` and `userAgent` helpers from the two existing reviewer controllers), and the `ContentTypeDetector` utility class (extracting the `detectContentType` logic from `ReviewerPaperServiceImpl` into a shared location). It also modifies three existing files (`ReviewerPaperController.java`, `ReviewController.java`, and `ReviewerPaperServiceImpl.java`). B2b will build the HTTP controller and global exception handler extensions on top of B2a's service layer. The headline design decision is the **service flow ordering** — reason validation, then paper load, then fail-closed audit write, then byte fetch — which determines how failures interact and whether the audit row exists in each failure scenario. The primary risks are: (1) the fail-closed audit call must not be wrapped in any try/catch within the access service, which requires discipline during implementation; (2) the `ManuscriptContent` record from the reviewer feature is reused for admin access, creating a minor naming inconsistency in its Javadoc; (3) the helper extraction refactor touches two existing controllers and must be byte-identical in behavior; (4) the content type detection extraction touches `ReviewerPaperServiceImpl` (Phase 1 code), but this is a pure refactor with byte-identical behavior, not a design change.

---

## 2. AdminPaperAccessService — Orchestration Layer

### 2.1 Reference Pattern to Match

The closest existing reference is `ReviewerPaperServiceImpl` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/service/impl/ReviewerPaperServiceImpl.java`. Its conventions:

- **Interface package**: `com.shodh.sanchayan.service` (file: `ReviewerPaperService.java` at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/service/ReviewerPaperService.java`)
- **Implementation package**: `com.shodh.sanchayan.service.impl`
- **Lombok annotations**: `@RequiredArgsConstructor` and `@Slf4j` at the class level on the implementation
- **Spring annotation**: `@Service` at the class level on the implementation
- **Transaction annotation**: `@Transactional(readOnly = true)` at the class level (line 60 of `ReviewerPaperServiceImpl`)
- **Constructor injection**: `@RequiredArgsConstructor` with `private final` fields for all dependencies
- **Storage interaction**: Calls `storageService.download(key)` which returns an `InputStream` (confirmed at `StorageService.java` line 12: `InputStream download(String key)`). The reviewer service reads the stream to bytes via `try (InputStream is = storageService.download(key)) { raw = is.readAllBytes(); }` wrapped in a catch for `StorageException | IOException` that translates to `ManuscriptNotFoundException` (lines 117-123 of `ReviewerPaperServiceImpl`)
- **Primary method name**: `getManuscriptForReview` (line 72 of `ReviewerPaperServiceImpl`)

The new `AdminPaperAccessService` matches `ReviewerPaperServiceImpl`'s conventions for package location, Spring annotations, injection style, and logging, with the following specific differences:

- **No `ReviewAuthorizationService` dependency** — admin access is not review-scoped; there is no assignment check
- **No `ReviewerPaperAgreementService` dependency** — admin access bypasses the per-paper confidentiality agreement gate per the locked Q5 decision
- **No PDF transformation dependencies** — no `PdfMetadataStripper`, no `PdfWatermarker`. Admin sees the raw manuscript bytes per Q5
- **Different audit service dependency** — the admin service injects `AdminPaperAccessAuditService` (from B1), not `PaperAccessAuditService` (the reviewer audit)
- **No `PaperAccessAuditService` dependency** — that is the reviewer audit service; the admin service uses only `AdminPaperAccessAuditService`

### 2.2 Service Interface and Implementation — Package, Files, Contract

**Files:**

- `AdminPaperAccessService` interface — in package `com.shodh.sanchayan.service`, alongside `ReviewerPaperService` and B1's `AdminPaperAccessAuditService`. File name: `AdminPaperAccessService.java`.
- `AdminPaperAccessServiceImpl` implementation — in package `com.shodh.sanchayan.service.impl`, alongside `ReviewerPaperServiceImpl` and B1's `AdminPaperAccessAuditServiceImpl`. File name: `AdminPaperAccessServiceImpl.java`.

**Method name:** `getManuscriptForAdmin`. This is the parallel to the reviewer service's `getManuscriptForReview` method. The naming parallel makes the service comparison obvious to any reader looking at both services side by side.

**Method purpose:** Fetch a paper manuscript for admin review, writing an audit row synchronously and fail-closed before returning the bytes.

**Parameters:**

- Admin user UUID — the identity of the admin performing the access
- Paper UUID — the paper to access
- Access type — `AdminPaperAccessType` from B1 (either preview or download, determined by the controller based on which endpoint was hit)
- Reason string — the admin's stated justification for accessing the paper
- IP address string — extracted by the controller from the HTTP request
- User-agent string — extracted by the controller from the HTTP request

**Return value:** The existing `ManuscriptContent` record at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/service/dto/ManuscriptContent.java`. This record has the shape `(byte[] bytes, String referenceNo, String contentType)` — exactly what the admin service needs. It is reused without changes. Its Javadoc mentions "reviewer" in passing, which is a minor inconsistency now that both admin and reviewer services use it; this cosmetic concern is not worth creating a second DTO.

**Semantic contract:** If the method returns normally, the audit row has been durably committed AND the manuscript bytes are fetched and ready to return. If the method throws, the caller (the controller in B2b) must NOT return bytes to the admin in any failure path. Fail-closed is end-to-end: audit failure, validation failure, storage failure, paper-not-found, or any other exception all produce the same outcome at the HTTP layer (no bytes returned).

The interface Javadoc must explicitly document the fail-closed contract and the no-transformation behavior (no metadata stripping, no watermarking, no agreement gate). It must reference B1's `AdminPaperAccessAuditService` as the underlying audit mechanism and state that the audit write uses `REQUIRES_NEW` propagation, so the audit row is durable even if the caller's transaction (if any) subsequently fails.

### 2.3 Service Flow — The Critical Decision

The flow consists of five logical steps.

**Step A — Reason validation (server-side):**

The service validates the reason parameter as defense in depth, even though the controller (designed in B2b) will also validate. The validation checks:

- Reason is not null
- Reason, after `String.trim()`, has length >= 10 characters
- Reason is not composed entirely of whitespace (this is subsumed by the trim + length check — a whitespace-only reason trims to an empty string, which has length 0, failing the >= 10 check)

If validation fails, throw `InvalidReasonException` (designed in Section 3) with an error code identifying the specific failure.

Rationale for server-side validation: the controller layer will also validate, but defense in depth means the service does not trust its caller. If a future caller — a test, a scheduled job, another internal service — invokes the admin access service without going through the HTTP controller, the service still enforces the reason requirement. This is the same defense-in-depth principle B1 applied with the DB-level CHECK constraint: multiple layers catch the same rule. The system has three layers of reason validation — controller (HTTP layer), service (business layer), and database CHECK constraint (persistence layer).

**Step B — Load paper metadata:**

The service loads the paper entity via the existing `PaperRepository.findById(paperId)`. If the paper is not found, throw `ResourceNotFoundException` (the existing exception at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/exception/ResourceNotFoundException.java`), matching the pattern used throughout `PaperServiceImpl` (e.g., line 73-74 of `PaperServiceImpl`).

Also verify the paper has a manuscript file attached. The service checks the paper's `manuscriptPdfKey` first (the auto-converted PDF from DOCX uploads), falling back to `manuscriptKey` (the original upload). This matches the key-preference logic in `ReviewerPaperServiceImpl` (lines 100-109). If both keys are null or blank, throw `ManuscriptNotFoundException` (the existing exception at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/exception/ManuscriptNotFoundException.java`). This distinction matters — "paper not found" (404) and "paper found but no manuscript uploaded" (404 with `MANUSCRIPT_NOT_FOUND` code) are different error conditions that produce different user-facing messages via the `GlobalExceptionHandler` (lines 67-71 and 19-21).

**Step C — Call the audit service (fail-closed audit write):**

The service calls B1's `AdminPaperAccessAuditService.logAccess` with the admin user ID, paper ID, access type, the validated reason (the original untrimmed reason string — trimming is for validation only; the audit table stores the reason as the admin typed it), IP address, and user-agent.

This call is synchronous and must complete successfully before Step D runs.

If `logAccess` throws any exception (`DataAccessException`, `DataIntegrityViolationException`, `TransactionSystemException`, any other Spring or JPA exception), the access service does NOT catch it. The exception propagates unmodified to the controller. No try/catch. No logging at this layer for the audit failure.

Per B1's `REQUIRES_NEW` transaction propagation, the audit row is committed in its own transaction. If Step D subsequently fails, the audit row is already durable and will not be rolled back.

The call goes through a Spring AOP proxy because `AdminPaperAccessAuditService` is a separate `@Service` bean injected via constructor injection into `AdminPaperAccessServiceImpl`. This is structurally safe against the Spring self-invocation proxy bypass — the call is from one bean to another, not a `this` call within the same class. This safety property is verified by construction: `AdminPaperAccessServiceImpl` and `AdminPaperAccessAuditServiceImpl` are different classes in different files.

**Step D — Fetch manuscript bytes from storage:**

The service calls the existing `StorageService.download(key)` method to fetch the raw manuscript bytes, where `key` is the manuscript key determined in Step B. Matching the reviewer service's pattern (lines 117-123 of `ReviewerPaperServiceImpl`), the method returns an `InputStream` which is consumed via try-with-resources and `readAllBytes()` to produce a `byte[]`.

If the storage fetch fails (`StorageException`, `IOException`, or any storage-layer exception), the service throws `ManuscriptNotFoundException`, translating the storage failure into a user-facing "manuscript unavailable" error. This is the same translation pattern the reviewer service uses at lines 120-123 of `ReviewerPaperServiceImpl`.

**Critical failure-mode property:** If Step D fails, the audit row from Step C is already committed (because of `REQUIRES_NEW`). This is intentional. The audit record shows "at time T, admin X requested paper Y with reason Z, and the system attempted to serve bytes." The fact that byte delivery failed after the audit decision is a separate event, and the audit trail correctly records that the admin's access was approved even if the final delivery failed.

**Step E — Return the result:**

Construct and return a `ManuscriptContent` record with the raw bytes (no transformation), the paper's reference number, and the content type determined by `ContentTypeDetector.detect(bytes, key)` (the shared utility extracted from the reviewer service — see Section 5).

No metadata stripping, no watermarking, no agreement gate — per Q5. These are deliberate omissions. The admin sees the manuscript exactly as stored. Admins are editorial staff with full access rights, and the audit trail (with mandatory reason) is the accountability mechanism, not transformation of the bytes.

### 2.4 Flow Ordering — Why This Order?

The order is A → B → C → D → E.

**Why validation (A) before paper load (B)?** If the reason is invalid, the service fails fast without touching the database. This avoids a useless `findById` call for a request that cannot succeed. Validation is cheap and deterministic (pure string operations); paper load is a DB round trip.

**Why paper load (B) before audit write (C)?** If the paper does not exist or has no manuscript, the service should not write an audit row for a non-existent access. The audit table records legitimate access events, not 404s. Writing an audit row for a paper that doesn't exist would create audit noise — every mistyped paper ID or stale link click would generate an audit row with a valid admin ID and reason but pointing at a non-existent paper.

The counter-argument is acknowledged: "audit the attempt, not just the success — compliance investigators want to see that admin X tried to access paper Y even if Y doesn't exist." This is a reasonable alternative framing that would capture suspicious probing behavior. However, it conflicts with the purpose of the admin audit table as designed in B1. The `admin_paper_access_audit` table captures "admin X saw the manuscript bytes for paper Y because of reason Z" — it is a record of successful access decisions, not of every attempted HTTP request. The HTTP access log already captures every request, including 404s, and is the standard tool for detecting probing behavior.

Decision: B before C (paper load before audit write).

**Why audit write (C) before byte fetch (D)?** Three reasons:

First, the audit decision is the compliance event. The moment the audit row is committed, the system has made and recorded the decision "admin X is authorized to see paper Y for reason Z." Everything after that is delivery mechanics. Committing the audit before the fetch means the compliance record exists even if the fetch fails.

Second, failure in the fetch does not invalidate the decision. If the storage layer is down, the audit row should still exist — the decision to serve the paper was made, and the reason the admin provided is recorded. Compliance investigators can see "admin requested this paper, system approved and logged it, delivery failed" as a coherent record.

Third, failure in the audit write invalidates the entire operation. If the audit write fails, the service throws before even attempting the fetch. The admin never sees bytes. The state is consistent: no audit row, no bytes, no event.

The alternative (fetch first, audit second) is rejected because it creates a window where bytes are in memory but not yet audited. If the service crashes between fetch and audit, or if the audit write fails after a successful fetch, the admin could theoretically have seen bytes without an audit row — a compliance hole.

**Why return (E) is at the end:** The return happens only after all four preceding steps have succeeded. Any exception from any step short-circuits the return.

### 2.5 Transaction Semantics

The reviewer service (`ReviewerPaperServiceImpl`) has `@Transactional(readOnly = true)` at the class level (line 60). B2a matches this convention: the `AdminPaperAccessServiceImpl` class gets `@Transactional(readOnly = true)` at the class level.

Justification:

- Step A (validation) is pure Java, no DB access — unaffected.
- Step B (paper load) is a read operation; `@Transactional(readOnly = true)` provides the correct semantics and the `readOnly = true` hint lets the JPA provider optimize.
- Step C (audit write) runs in its own `REQUIRES_NEW` transaction per B1's design. The outer method's `readOnly = true` transaction is suspended while the audit write executes in its own read-write transaction. `REQUIRES_NEW` creates a completely new transaction context, so the outer method's `readOnly` setting does not constrain the audit write.
- Step D (storage fetch) is an external I/O operation, not a database operation — unaffected.
- Step E (construct and return) is pure Java.

The access service method does NOT need `@Transactional` (read-write) at the method level because it has no persistence operations of its own. The audit write's persistence is handled by B1's `REQUIRES_NEW` boundary.

### 2.6 Dependencies and Injection

Injected dependencies via `@RequiredArgsConstructor` with `private final` fields:

- `AdminPaperAccessAuditService` (from B1) — for the fail-closed audit write
- `PaperRepository` (existing) — for loading the paper entity
- `StorageService` (existing) — for fetching manuscript bytes

**Explicitly excluded dependencies** (listed so the implementation prompt doesn't accidentally inject them):

- No `ReviewAuthorizationService` — admin access is not review-scoped
- No `ReviewerPaperAgreementService` — admin bypasses the agreement gate
- No `PdfMetadataStripper` — admin sees raw bytes
- No `PdfWatermarker` — admin sees raw bytes
- No `PaperAccessAuditService` — that's the reviewer audit, not the admin audit
- No `EntityManager` — the service does not directly interact with JPA beyond the repository
- No `ReviewRepository` — admin access is paper-scoped, not review-scoped

### 2.7 Observability

Logging conventions match the reviewer service: `@Slf4j` at the class level.

**INFO on successful return only.** A log line containing admin user ID, paper ID, access type, and reason. Format in prose: "Admin manuscript access granted — adminId={}, paperId={}, type={}, reason={}". This is the only log line the access service produces.

**No ERROR or WARN logging within the access service.** On any failure — audit write failure, storage failure, validation failure, paper-not-found — the exception propagates unmodified to the controller layer, where the `GlobalExceptionHandler`'s `RuntimeException` catch-all handler (line 104-108 of `GlobalExceptionHandler.java`) produces the ERROR log with the full stack trace. This approach is simpler and avoids the maintenance landmine of a try/catch-and-rethrow pattern where a future maintainer might accidentally remove the rethrow and silently break fail-closed semantics.

**Two logging rules (matching B1):**

- Log the reason. The reason is editorial metadata and belongs in the operational log for investigation correlation.
- Never log manuscript bytes, storage keys, paper titles, or paper content.

### 2.8 Test Strategy

Test files go in `src/test/java/com/shodh/sanchayan/service/impl/`, matching the project's package structure convention.

**Unit test 1 — successful flow:** Given valid inputs (admin UUID, paper UUID, access type, 15-character reason, IP address, user-agent), mock the paper repository to return a paper with a manuscript key, mock the audit service to succeed, mock the storage service to return bytes. Verify: (a) the audit service is called before the storage service (verify call ordering via mock framework's in-order verification), (b) the returned `ManuscriptContent` has the raw bytes from storage (not transformed), (c) the reference number matches the paper's reference number.

**Unit test 2 — reason too short:** Given a 9-character reason (after trim), verify `InvalidReasonException` is thrown with the `REASON_TOO_SHORT` code, and neither the audit service nor the storage service is called.

**Unit test 3 — reason null:** Given a null reason, verify `InvalidReasonException` is thrown with the `REASON_REQUIRED` code.

**Unit test 4 — reason whitespace-only:** Given a 20-character reason composed entirely of spaces, verify `InvalidReasonException` is thrown. After trim, the reason is empty (length 0), which fails the >= 10 check.

**Unit test 5 — paper not found:** Given a paper ID that returns empty from `findById`, verify `ResourceNotFoundException` is thrown and neither the audit service nor the storage service is called.

**Unit test 6 — paper has no manuscript:** Given a paper with null `manuscriptKey` and null `manuscriptPdfKey`, verify `ManuscriptNotFoundException` is thrown and neither the audit service nor the storage service is called.

**Unit test 7 — audit failure propagates (fail-closed verification):** Mock the paper repository to return a valid paper. Mock the audit service to throw `DataIntegrityViolationException`. Verify the exception propagates out of the access service unmodified AND the storage service is NOT called. This is the most important unit test — it verifies the fail-closed contract.

**Unit test 8 — storage failure after audit:** Mock the paper repository to return a valid paper. Mock the audit service to succeed. Mock the storage service to throw `StorageException`. Verify `ManuscriptNotFoundException` is thrown (translating the storage failure). Verify the audit service was called before the storage service.

**Unit test 9 — no transformation applied:** Given a successful fetch, verify the returned `ManuscriptContent` bytes are exactly reference-equal (or content-equal) to what the storage service returned. No stripping, no watermarking, no modification.

**Integration test — end-to-end fail-closed (candidate for deferral):** Using a real test database, simulate an audit failure (e.g., by providing a reason that fails the DB-level CHECK constraint), verify the access service throws and the storage service is never called. Candidate for deferral to B2b's end-to-end test strategy if the test harness is awkward.

---

## 3. InvalidReasonException — New Exception Class

### 3.1 Reference Pattern

The closest reference is `InvalidManuscriptException` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/exception/InvalidManuscriptException.java`. Its conventions:

- **Package**: `com.shodh.sanchayan.exception`
- **Parent class**: extends `RuntimeException` directly (not `BusinessException`)
- **Constructor signature**: takes a `String code` and a `String message`. A second constructor also accepts a `Throwable cause`.
- **Error code field**: `private final String code` with a `getCode()` accessor method
- **No `@ResponseStatus` annotation** — HTTP status mapping is handled by the `GlobalExceptionHandler`, which has a dedicated `@ExceptionHandler(InvalidManuscriptException.class)` method (line 90-94 of `GlobalExceptionHandler.java`) that returns 400 with the code and message

B2a's `InvalidReasonException` matches this convention exactly: extends `RuntimeException`, has a `String code` field with constructor and accessor, no `@ResponseStatus`.

**Error codes — two codes, one per failure mode:**

- `REASON_REQUIRED` — thrown when the reason is null. The message conveys that a reason must be provided.
- `REASON_TOO_SHORT` — thrown when the reason, after trim, has fewer than 10 characters. This subsumes the "whitespace-only" case: a whitespace-only reason trims to empty string (length 0), which is fewer than 10 characters.

This matches the existing codebase's pattern. `InvalidManuscriptException` uses distinct codes like `MANUSCRIPT_EMPTY`, `MANUSCRIPT_TOO_LARGE`, `MANUSCRIPT_NOT_PDF` — one code per failure mode, each mapping to a specific user-facing message.

### 3.2 Where the Exception Is Thrown

Only in `AdminPaperAccessServiceImpl.getManuscriptForAdmin`, at Step A (reason validation). The controller in B2b will also validate the reason at the HTTP layer, but the service is the defense-in-depth layer.

The exception propagates from the service to the controller. In B2b, the `GlobalExceptionHandler` will be extended with a handler for `InvalidReasonException` that produces a 400 Bad Request response with the specific error code. B2a does NOT modify the `GlobalExceptionHandler` — that is B2b's responsibility.

### 3.3 Javadoc Requirements

The exception class Javadoc must state:

- It is thrown when reason validation fails at the service layer in `AdminPaperAccessService`
- The intended HTTP status is 400 Bad Request (documented for the global exception handler's reference in B2b)
- Error codes: `REASON_REQUIRED` (reason is null or missing) and `REASON_TOO_SHORT` (reason has fewer than 10 characters after trim, including the whitespace-only case)
- It is a defense-in-depth check; the controller also validates, and the DB has a CHECK constraint — three layers catching the same rule

---

## 4. Helper Extraction Refactor — `clientIp` and `userAgent` Utilities

### 4.1 Current State

Two identical copies of `clientIp(HttpServletRequest)` and `userAgent(HttpServletRequest)` exist:

- `ReviewerPaperController.java` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/controller/api/ReviewerPaperController.java`, lines 140-152. Both methods are `private static`. The controller also has an `extensionFor(String contentType)` helper at lines 154-163 which is NOT part of this extraction (it is controller-specific).

- `ReviewController.java` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/controller/api/ReviewController.java`, lines 88-100. Both methods are `private static`, identical to the copies in `ReviewerPaperController`.

Both controller files include comments (lines 131-137 and 80-86 respectively) explicitly documenting the duplication and the "two-copy tradeoff" decision. These comments will be removed during the refactor since the duplication is eliminated.

### 4.2 Target State — Shared Utility Class

**Location and package:** `com.shodh.sanchayan.util` at path `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/util/`. This package contains six existing utility classes: `SecurityUtils`, `ManuscriptHasher`, `PdfMetadataStripper`, `PdfWatermarker`, `ManuscriptValidator`, and `DocxToPdfConverter`.

**Class name:** `HttpRequestUtils`. Matches the naming convention of `SecurityUtils` in the same package.

**Class structure:** The existing `SecurityUtils` at `shodh-sanchayan-api/src/main/java/com/shodh/sanchayan/util/SecurityUtils.java` (line 10) uses the explicit `public final class` with `private` constructor pattern — no Lombok `@UtilityClass` annotation. B2a matches this convention: `HttpRequestUtils` is a `public final class` with a `private` no-arg constructor, and all methods are `public static`.

**Method descriptions (in prose):**

- `clientIp` takes an `HttpServletRequest` and returns a `String`. The method extracts the client IP by checking the `X-Forwarded-For` header first (taking the first IP before any comma, trimmed), falling back to `request.getRemoteAddr()`. This matches the existing implementation at `ReviewerPaperController.java` lines 140-147 verbatim. Note: the existing implementation does NOT check `X-Real-IP` — it only checks `X-Forwarded-For` and then falls back to `getRemoteAddr()`. B2a copies the existing behavior exactly.

- `userAgent` takes an `HttpServletRequest` and returns a `String`. The method extracts the `User-Agent` header. If the header is absent, returns null. If present and longer than 500 characters, truncates to 500 characters. This matches the existing implementation at `ReviewerPaperController.java` lines 149-152 verbatim.

The implementation prompt will read the existing reviewer controller's helper methods and copy the logic verbatim into the utility class. No logic changes during extraction. This is a pure refactor.

### 4.3 Refactor Steps for Existing Reviewer Controllers

After the utility class is created:

**`ReviewerPaperController.java`:** Remove the `clientIp` and `userAgent` private static methods (lines 140-152) and the comment block above them (lines 131-137). Replace the call sites with `HttpRequestUtils.clientIp(request)` and `HttpRequestUtils.userAgent(request)`. Add an import for `HttpRequestUtils`. The `extensionFor` helper stays — it is controller-specific.

**`ReviewController.java`:** Remove the `clientIp` and `userAgent` private static methods (lines 88-100) and the comment block above them (lines 80-86). Replace the call sites with `HttpRequestUtils.clientIp(request)` and `HttpRequestUtils.userAgent(request)`. Add an import for `HttpRequestUtils`.

No behavior changes. The refactor is byte-identical in behavior.

### 4.4 Sequencing Concern

Recommend deploying the helper extraction immediately (Option A). The refactor is byte-identical in behavior; deploying it immediately has zero runtime risk. The utility class sits in place ready for B2b's controller to consume it.

### 4.5 Test Strategy for the Refactor

**Regression tests for existing controllers:** If existing tests cover the reviewer controllers' IP and user-agent extraction, they will continue to pass after the refactor without modification because the behavior is unchanged.

**New unit tests for `HttpRequestUtils`:**

- `clientIp` with `X-Forwarded-For` header set to a single IP — verify the IP is returned
- `clientIp` with `X-Forwarded-For` header containing multiple comma-separated IPs — verify the first IP is returned, trimmed
- `clientIp` with `X-Forwarded-For` header null/blank — verify `getRemoteAddr()` is returned
- `userAgent` with `User-Agent` header present and under 500 characters — verify the header value is returned
- `userAgent` with `User-Agent` header present and over 500 characters — verify truncation to 500
- `userAgent` with `User-Agent` header absent — verify null is returned

---

## 5. Content Type Detection Extraction — `ContentTypeDetector` (OVERRIDE — RESOLVED)

### 5.1 Background

The reviewer service (`ReviewerPaperServiceImpl`, lines 153-187) has a `detectContentType` private static method and associated `PDF_MAGIC` / `ZIP_MAGIC` byte-array constants that determine the content type from raw bytes and a storage key. The admin access service needs the same logic for Step E.

### 5.2 Decision

Extract `detectContentType` into a shared utility class rather than duplicating it. The "Phase 1 code is frozen" constraint exists to prevent design churn on approved Phase 1 decisions. Extracting a private static method into a utility class is a pure refactor with byte-identical behavior — not a design change, just code organization.

Rationale: if a new file format is added (ODT, etc.), one method should be updated, not two. And if Phase 4 adds a third consumer, extraction would be forced anyway.

### 5.3 Target State

**Class name:** `ContentTypeDetector`

**Location:** `com.shodh.sanchayan.util` — the same package as `HttpRequestUtils`, `SecurityUtils`, and the other utility classes.

**Class structure:** `public final class` with `private` constructor, matching the `SecurityUtils` convention. No Lombok `@UtilityClass`.

**Contents:** The `detect` public static method and the `PDF_MAGIC` / `ZIP_MAGIC` private static byte-array constants, copied verbatim from `ReviewerPaperServiceImpl` lines 153-187 with no behavioral changes. The `startsWith` private static helper method (lines 182-186) is also included.

**Method signature (described in prose):** The method takes a `byte[]` (the raw manuscript bytes) and a `String` (the storage key, for extension-based fallback) and returns a `String` (the MIME content type). Matching the existing implementation exactly.

### 5.4 Refactor of ReviewerPaperServiceImpl

`ReviewerPaperServiceImpl` is updated to call `ContentTypeDetector.detect(raw, key)` instead of the inline `detectContentType(raw, key)`. The private `detectContentType` method, the `PDF_MAGIC` constant, the `ZIP_MAGIC` constant, and the `startsWith` helper are removed from `ReviewerPaperServiceImpl`. An import for `ContentTypeDetector` is added.

This is a single-line change at the call site (line 127 of `ReviewerPaperServiceImpl`), plus removal of the now-unused private method and constants (lines 153-187).

### 5.5 Usage by AdminPaperAccessServiceImpl

`AdminPaperAccessServiceImpl` calls `ContentTypeDetector.detect(bytes, key)` in Step E to determine the content type for the `ManuscriptContent` return value. No duplication of the detection logic.

---

## 6. Unexpected Prerequisites

No unexpected prerequisites surfaced during B2a specification beyond the content type detection issue resolved in Section 5.

---

## 7. B2a Rollback Plan

### 7.1 New Files Rollback

Rollback of the new access service, exception class, and utility classes involves reverting the commits that introduced them:

- `AdminPaperAccessService` interface
- `AdminPaperAccessServiceImpl` implementation
- `InvalidReasonException`
- `HttpRequestUtils` utility class
- `ContentTypeDetector` utility class

Then rebuild and redeploy.

### 7.2 Reviewer Controller and Service Refactor Rollback

The refactor of `ReviewerPaperController`, `ReviewController`, and `ReviewerPaperServiceImpl` to use the extracted utilities can be reverted independently of the new access service by reverting just the refactor commit. After revert, the controllers and service go back to having inline helper copies.

Recommendation: keep the refactors in a **separate commit** from the new access service. This allows independent rollback.

### 7.3 Data Loss Consideration

B2a adds no database tables, writes no rows, and has no stateful changes beyond code deployment. Rollback has zero data loss risk.

---

## 8. Implementation Sequence Within B2a

1. **`HttpRequestUtils`** — the utility class. No dependencies on anything B2a-new. Place in `com.shodh.sanchayan.util`.

2. **`ContentTypeDetector`** — the content type utility class. No dependencies on anything B2a-new. Can be implemented in parallel with step 1. Place in `com.shodh.sanchayan.util`.

3. **`InvalidReasonException`** — the exception class. No dependencies on anything B2a-new. Can be implemented in parallel with steps 1-2. Place in `com.shodh.sanchayan.exception`.

4. **Refactor existing files** — update `ReviewerPaperController` and `ReviewController` to use `HttpRequestUtils`; update `ReviewerPaperServiceImpl` to use `ContentTypeDetector`. Depends on steps 1 and 2. **Separate commit.**

5. **`AdminPaperAccessService` interface** — depends on the existing `ManuscriptContent` record and B1's `AdminPaperAccessAuditService` interface. Place in `com.shodh.sanchayan.service`.

6. **`AdminPaperAccessServiceImpl`** — depends on the interface (step 5), the exception (step 3), `ContentTypeDetector` (step 2), and B1's audit service. Place in `com.shodh.sanchayan.service.impl`. **Separate commit from step 4.**

Steps 1-3 are independent and can be done in parallel. Step 4 depends on steps 1-2. Steps 5-6 are sequential. Recommend two commits: commit 1 is the refactor (steps 1-4), commit 2 is the new access service (steps 5-6).

---

## 9. Open Design Questions for Caller Review

**Question 1 — Content type detection (RESOLVED):** Extract into `ContentTypeDetector` shared utility class. Do not duplicate. The extraction is a pure refactor of Phase 1 code, not a design change.

**Question 2 — Observability on audit failure (RESOLVED):** No try/catch in the access service. INFO on success only. Let the `GlobalExceptionHandler`'s catch-all `RuntimeException` handler produce the ERROR log on audit failure.

No open design questions remain.

---

## 10. Summary of B2a Deliverables

**New files (5):**

- `AdminPaperAccessService.java` — service interface in `com.shodh.sanchayan.service` with fail-closed contract Javadoc
- `AdminPaperAccessServiceImpl.java` — service implementation in `com.shodh.sanchayan.service.impl` with the A→B→C→D→E flow, `@Transactional(readOnly = true)` at class level, and no try/catch around the audit call
- `InvalidReasonException.java` — exception class in `com.shodh.sanchayan.exception` with code/message constructor matching `InvalidManuscriptException` pattern
- `HttpRequestUtils.java` — utility class in `com.shodh.sanchayan.util` with `clientIp` and `userAgent` static methods, matching `SecurityUtils` class structure
- `ContentTypeDetector.java` — utility class in `com.shodh.sanchayan.util` with `detect` static method and magic-byte constants extracted from `ReviewerPaperServiceImpl`

**Modified files (3):**

- `ReviewerPaperController.java` — remove inline `clientIp`/`userAgent` helpers, use `HttpRequestUtils`
- `ReviewController.java` — remove inline `clientIp`/`userAgent` helpers, use `HttpRequestUtils`
- `ReviewerPaperServiceImpl.java` — remove inline `detectContentType`/`PDF_MAGIC`/`ZIP_MAGIC`/`startsWith`, use `ContentTypeDetector.detect`

**New tables:** 0

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

**Total files touched:** 8 (5 new + 3 modified)

**Explicitly out of scope for B2a (deferred to B2b):**

- `AdminPaperAccessController` (HTTP endpoints, response shape, Content-Disposition headers)
- Auth rule changes in `SecurityConfig.java` and `ReviewerPaperController.java`
- `GlobalExceptionHandler` extension for `InvalidReasonException`, `AUDIT_WRITE_FAILED`, and other new error codes
- End-to-end integration test strategy for the full admin manuscript access flow
- Frontend work
