# Phase 2B-iii — Annotated Paper Re-upload Design

**Status:** Approved (2026-04-12)
**Scope:** Allow a reviewer who has downloaded the metadata-stripped manuscript (via Phase 2A) to upload an annotated PDF back against their own review record, and surface an advisory "hash matches original" flag so reviewers can tell at a glance whether the file they are re-uploading is derived from the manuscript they were given. Forward-only hash strategy — no backfill for legacy papers.

This phase does NOT implement any code. It is the canonical design reference for Phase 3 implementation of annotated paper re-upload.

---

## 1. Goals and non-goals

### Goals
1. A reviewer assigned to a paper can upload an annotated PDF (their own markup on top of the manuscript they downloaded in Phase 2A) against their review record.
2. The annotated file is stored in isolation from the original manuscript — a separate folder tree, a separate DB row, a separate ownership chain. The original manuscript key in `papers.manuscript_key` is never touched, overwritten, or re-hashed.
3. Re-uploading replaces any prior annotation for the same review, using `save new → update DB → delete old` ordering so the reviewer's latest upload is durable even if the cleanup step fails.
4. A SHA-256 hash of the original manuscript is computed at upload time (in `PaperServiceImpl.submit` and `PaperServiceImpl.uploadRevision`) and persisted on the `papers` row. At annotation upload time the service compares the annotation's bytes-hash against the paper's stored hash and returns an advisory `matchesOriginal` flag. The match result is informational only and never causes an HTTP rejection.
5. Upload-time PDF validation (Phase 2A's `ManuscriptValidator`) is reused verbatim so annotated PDFs receive the same magic-byte / encryption / parse / size checks as manuscripts.
6. Authorization is strict: the caller must be the reviewer on the target review record, and the review must be in a state where annotation still makes sense (`PENDING` or `IN_PROGRESS`).

### Non-goals
- Retroactive hashing of already-submitted papers. Pre-V9 papers keep `manuscript_hash = NULL` forever.
- Server-side annotation rendering, diffing, or merging. The annotated PDF is stored as an opaque blob.
- Distinguishing "different file" from "file with annotations added." Any byte-level difference is a hash mismatch, and the reviewer should expect `matchesOriginal = false` whenever they have added marks.
- Auditing of annotation uploads into `paper_access_audit`. That table is 2B-i's read/download audit and stays focused on that concern. A future phase may add annotation-upload audit via the existing generic `auditService.log(...)` path — see §5.4.
- Any change to the `PaperDetailResponse` contract or the single-gateway rule established by the ADR.

---

## 2. Backend design

### 2.1 Schema changes (two migrations)

**V9 — add `manuscript_hash` to papers**

```sql
-- V9__add_manuscript_hash_to_papers.sql
ALTER TABLE papers
    ADD COLUMN manuscript_hash VARCHAR(64);

COMMENT ON COLUMN papers.manuscript_hash IS
  'Lowercase hex SHA-256 of the manuscript bytes at upload time. NULL for papers submitted before V9 (forward-only; no backfill).';
```

No index. The column is only read from the annotation service against a single `papers` row already loaded by primary key.

**V10 — create `review_annotations`**

```sql
-- V10__create_review_annotations.sql
CREATE TABLE review_annotations (
    id               UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    review_id        UUID        NOT NULL,
    storage_key      VARCHAR(512) NOT NULL,
    original_filename VARCHAR(255) NOT NULL,
    size_bytes       BIGINT      NOT NULL,
    content_type     VARCHAR(100) NOT NULL DEFAULT 'application/pdf',
    matches_original BOOLEAN     NOT NULL,
    uploaded_at      TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at       TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT fk_review_annotations_review FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
    CONSTRAINT uq_review_annotations_review UNIQUE (review_id)
);
```

`UNIQUE(review_id)` enforces the 1:1 optional relationship at the database level — the concurrent-insert path is closed by the DB constraint, not by application-level locking. `ON DELETE CASCADE` lets a review deletion (administrative operation, out of scope here) clean up orphaned annotation rows; the corresponding file blob is a disk-sweep concern handled offline.

**Why two migrations, not one:** Flyway best practice of one logical change per migration, and it matches the 2B-i/2B-ii precedent of keeping schema concerns narrowly scoped. V9 touches an existing table, V10 creates a new table — different review/rollback risks, different review lenses.

### 2.2 Entity

```java
// entity/ReviewAnnotation.java
@Entity
@Table(name = "review_annotations")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class ReviewAnnotation {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "review_id", nullable = false, unique = true)
    private Review review;

    @Column(name = "storage_key", nullable = false, length = 512)
    private String storageKey;

    @Column(name = "original_filename", nullable = false, length = 255)
    private String originalFilename;

    @Column(name = "size_bytes", nullable = false)
    private Long sizeBytes;

    @Column(name = "content_type", nullable = false, length = 100)
    private String contentType;

    @Column(name = "matches_original", nullable = false)
    private Boolean matchesOriginal;

    @Column(name = "uploaded_at", nullable = false)
    private LocalDateTime uploadedAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    void prePersist() {
        LocalDateTime now = LocalDateTime.now();
        if (uploadedAt == null) uploadedAt = now;
        updatedAt = now;
    }

    @PreUpdate
    void preUpdate() {
        updatedAt = LocalDateTime.now();
    }
}
```

`@OneToOne` against `Review` with `unique = true` at the JPA layer mirrors the DB unique constraint for early failure during Hibernate schema validation.

### 2.3 Entity change to `Paper`

```java
// entity/Paper.java  (addition)
@Column(name = "manuscript_hash", length = 64)
private String manuscriptHash;
```

Nullable in DB and in Java. Only populated on new submits/revisions post-V9.

### 2.4 Repository

```java
// repository/ReviewAnnotationRepository.java
public interface ReviewAnnotationRepository extends JpaRepository<ReviewAnnotation, UUID> {
    Optional<ReviewAnnotation> findByReview_Id(UUID reviewId);
}
```

No other custom queries. The service either fetches the existing row (for replace) or builds a new one.

### 2.5 Hash computation helper

```java
// util/ManuscriptHasher.java
@Component
public class ManuscriptHasher {

    /**
     * Compute a lowercase hex SHA-256 digest over the given bytes.
     * Used by PaperServiceImpl (at submit / uploadRevision time) and
     * ReviewAnnotationServiceImpl (at annotation upload time) so both
     * paths produce bit-identical hashes comparable via String.equals.
     */
    public String hash(byte[] content) {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] digest = md.digest(content);
            return HexFormat.of().formatHex(digest); // lowercase, 64 chars
        } catch (NoSuchAlgorithmException e) {
            // SHA-256 is mandatory in every JDK; this branch is unreachable.
            throw new IllegalStateException("SHA-256 unavailable", e);
        }
    }
}
```

A Spring bean (not a static utility) because it is shared between `PaperServiceImpl` and `ReviewAnnotationServiceImpl` and because bean injection matches the codebase's existing DI style. `HexFormat` is Java 17+ and available natively on the project's Java 21 baseline.

### 2.6 Retroactive changes to `PaperServiceImpl`

**`submit(...)` — inside the existing `if (manuscript != null && !manuscript.isEmpty())` block, between `ManuscriptValidator.validate(manuscript)` (2A) and `storageService.upload(...)`:**

```java
manuscriptValidator.validate(manuscript);          // 2A (existing)
byte[] manuscriptBytes = manuscript.getBytes();    // new
String manuscriptHash = manuscriptHasher.hash(manuscriptBytes); // new
String manuscriptKey = storageService.upload("manuscripts",
        paper.getReferenceNo() + "_v1", manuscript);
paper.setManuscriptKey(manuscriptKey);
paper.setManuscriptName(manuscript.getOriginalFilename());
paper.setManuscriptSize(manuscript.getSize());
paper.setManuscriptHash(manuscriptHash);           // new
```

**`uploadRevision(...)` — same insertion pattern immediately before `storageService.upload(...)`:**

```java
manuscriptValidator.validate(manuscript);          // 2A (existing)
byte[] manuscriptBytes = manuscript.getBytes();    // new
String manuscriptHash = manuscriptHasher.hash(manuscriptBytes); // new
String manuscriptKey = storageService.upload("manuscripts",
        paper.getReferenceNo() + "_v" + nextVersion, manuscript);
// ... existing setters ...
paper.setManuscriptSize(manuscript.getSize());
paper.setManuscriptHash(manuscriptHash);           // new
```

The calls to `manuscript.getBytes()` reuse the same `MultipartFile` object that `storageService.upload(...)` will stream; Spring's `StandardMultipartFile` buffers small files in memory and spills large files to disk, and `getBytes()` is safe to call before `upload(...)` runs. Reading bytes twice is the pragmatic tradeoff — the alternative of refactoring `StorageService` to hash-while-uploading pulls hashing into the storage interface, which is a separation-of-concerns cost much larger than the one-time in-memory read.

No new failure modes introduced: `getBytes()` throws `IOException`, which `submit` and `uploadRevision` already propagate through their existing `throws`/`try`-handling (both methods already call `storageService.upload(...)` which has the same IO profile).

### 2.7 Service interface

```java
// service/ReviewAnnotationService.java
public interface ReviewAnnotationService {

    /**
     * Upload (or replace) the annotated PDF attached to a review.
     *
     * Authorization: caller must be the reviewer on the target review,
     * and the review must be in PENDING or IN_PROGRESS status. Enforced
     * inline (see ReviewAnnotationServiceImpl) because ReviewAuthorizationService
     * is paperId-keyed and this flow is reviewId-keyed.
     *
     * Replace ordering: save new → update DB → delete old. Never reversed.
     *
     * The ipAddress and userAgent parameters are accepted but currently
     * unused. They are reserved for a future phase that will route annotation
     * uploads through AuditService.log(...) alongside paper read/download
     * audit (see §5.4). Wiring them now avoids a cross-layer signature
     * change later.
     *
     * @param reviewerId the authenticated caller's user id
     * @param reviewId   the review to attach the annotation to
     * @param file       the uploaded PDF (validated by ManuscriptValidator)
     * @param ipAddress  client IP (currently unused; reserved for audit)
     * @param userAgent  client user-agent (currently unused; reserved for audit)
     * @return metadata describing the stored annotation plus matchesOriginal flag
     */
    AnnotationUploadResult uploadAnnotation(UUID reviewerId,
                                            UUID reviewId,
                                            MultipartFile file,
                                            String ipAddress,
                                            String userAgent);
}
```

```java
// service/dto/AnnotationUploadResult.java
public record AnnotationUploadResult(
        UUID annotationId,
        String filename,
        Long sizeBytes,
        LocalDateTime uploadedAt,
        Boolean matchesOriginal
) {}
```

### 2.8 Service implementation

```java
// service/impl/ReviewAnnotationServiceImpl.java
@Service
@RequiredArgsConstructor
@Transactional
public class ReviewAnnotationServiceImpl implements ReviewAnnotationService {

    private final ReviewRepository reviewRepository;
    private final ReviewAnnotationRepository annotationRepository;
    private final ManuscriptValidator manuscriptValidator;
    private final ManuscriptHasher manuscriptHasher;
    private final StorageService storageService;

    @Override
    public AnnotationUploadResult uploadAnnotation(UUID reviewerId,
                                                   UUID reviewId,
                                                   MultipartFile file,
                                                   String ipAddress,
                                                   String userAgent) {
        // ipAddress / userAgent are accepted for forward compatibility with
        // the audit path documented in §5.4. Not consumed in Phase 2B-iii.

        // 1. Load review + authorize (inline — this flow is reviewId-keyed)
        Review review = reviewRepository.findById(reviewId)
                .orElseThrow(() -> new ResourceNotFoundException("Review not found: " + reviewId));

        if (!review.getReviewer().getId().equals(reviewerId)) {
            throw new ForbiddenException("You are not the reviewer for this review");
        }

        ReviewStatus status = review.getStatus();
        if (status != ReviewStatus.PENDING && status != ReviewStatus.IN_PROGRESS) {
            throw new ForbiddenException("Annotations can only be uploaded while the review is pending or in progress");
        }

        // 2. Validate (reused from 2A — magic byte / encryption / parse / size)
        manuscriptValidator.validate(file);

        // 3. Read bytes once, hash, compare
        byte[] annotationBytes;
        try {
            annotationBytes = file.getBytes();
        } catch (IOException e) {
            throw new InvalidManuscriptException("MANUSCRIPT_UNREADABLE", "Could not read uploaded file");
        }

        String annotationHash = manuscriptHasher.hash(annotationBytes);
        String originalHash = review.getPaper().getManuscriptHash();
        boolean matchesOriginal = originalHash != null && originalHash.equals(annotationHash);
        // Null original hash = legacy paper submitted before V9.
        // Silent skip: matchesOriginal stays false. Documented limitation.

        // 4. Replace ordering: save new → update DB → delete old
        Optional<ReviewAnnotation> existing = annotationRepository.findByReview_Id(reviewId);
        String previousKey = existing.map(ReviewAnnotation::getStorageKey).orElse(null);

        String newKey = storageService.upload(
                "annotations/" + reviewId,
                file.getOriginalFilename(),
                file
        );

        ReviewAnnotation annotation = existing.orElseGet(ReviewAnnotation::new);
        annotation.setReview(review);
        annotation.setStorageKey(newKey);
        annotation.setOriginalFilename(file.getOriginalFilename());
        annotation.setSizeBytes(file.getSize());
        annotation.setContentType("application/pdf");
        annotation.setMatchesOriginal(matchesOriginal);
        ReviewAnnotation saved = annotationRepository.saveAndFlush(annotation);

        if (previousKey != null && !previousKey.equals(newKey)) {
            try {
                storageService.delete(previousKey);
            } catch (Exception e) {
                // Accepted risk E2: orphan blob. Log and continue — the user's
                // latest upload is durable and the DB row points to a readable file.
                log.warn("Failed to delete previous annotation blob {}: {}", previousKey, e.getMessage());
            }
        }

        return new AnnotationUploadResult(
                saved.getId(),
                saved.getOriginalFilename(),
                saved.getSizeBytes(),
                saved.getUploadedAt(),
                saved.getMatchesOriginal()
        );
    }
}
```

Authorization ordering: load → ownership → state → validate → hash → store → persist. The cheap checks run first. `manuscriptValidator.validate(...)` only runs after authorization passes so a non-reviewer probing the endpoint never gets as far as PDF parsing.

### 2.9 Controller

```java
// controller/api/ReviewController.java  (addition)
@PostMapping(value = "/{reviewId}/annotation", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<AnnotationUploadResult> uploadAnnotation(
        @PathVariable UUID reviewId,
        @RequestParam("file") MultipartFile file,
        HttpServletRequest request) {

    UUID reviewerId = SecurityUtils.getCurrentUserId();
    AnnotationUploadResult result = reviewAnnotationService.uploadAnnotation(
            reviewerId,
            reviewId,
            file,
            clientIp(request),   // helper reused from 2B-i §3.1
            userAgent(request)   // helper reused from 2B-i §3.1
    );
    return ResponseEntity.ok(result);
}
```

The `clientIp` / `userAgent` helpers are the same static utilities added in Phase 2B-i §3.1 for the audit path on `ReviewerPaperController`. They are reused verbatim here — the controller extracts them at the edge even though the service currently discards them, so when the audit path is wired up in a future phase no cross-layer signature change is required.

The handler is added to the existing `ReviewController` (base `/reviewer/reviews`, class-level `@PreAuthorize("hasAnyRole('REVIEWER', 'ADMIN')")`). The URL base already matches; adding a new handler is a two-line change. A new controller would split `/reviewer/reviews/*` across two files for no benefit.

---

## 3. Frontend design

### 3.1 New component

```
src/components/reviewer/AnnotatedPaperUpload.tsx
```

Props:

```typescript
interface AnnotatedPaperUploadProps {
  reviewId: string;
}
```

Internal state:
- `uploading: boolean`
- `uploadProgress: number` (0–100)
- `dragActive: boolean`
- `annotation: AnnotationUploadResult | null` (the most recent upload's metadata)
- `error: string | null`

Behavior:
1. Empty state: a drag-target box with label "Drag an annotated PDF here, or click to browse". A hidden `<input type="file" accept="application/pdf" />` is triggered on click.
2. Drag state: native HTML5 `onDragEnter` / `onDragOver` (with `preventDefault`) / `onDragLeave` / `onDrop`. No `react-dropzone`.
3. Client-side guard: reject non-PDF MIME types before uploading (`if (!file.type.includes('pdf')) { toast.error(...); return; }`). Server still enforces via `ManuscriptValidator`.
4. Upload: `await reviewApi.uploadAnnotation(reviewId, file, (pct) => setUploadProgress(pct))`. Progress is driven by axios's native `onUploadProgress` callback.
5. On success: store the `AnnotationUploadResult` in `annotation` state. Toast `"Annotated paper uploaded"`. If `matchesOriginal === true`, additionally fire `toast.warning("The file you uploaded appears identical to the original manuscript. Did you mean to upload your annotated version?")` to catch the reviewer-uploaded-the-wrong-file foot-gun (§2.12). If `matchesOriginal === false`, no additional toast beyond the success toast.

> **Reconciliation note — 2026-04-12 (drafting-error correction).** The original wording of this behavior inverted the toast condition: it called for an info toast on `matchesOriginal === false`. That was a drafting error. The entire purpose of the §2.12 hash comparison is to catch the case where a reviewer re-uploads the byte-identical original by mistake; firing a toast on every annotated upload (where mismatch is expected) is noise, and staying silent on the match case defeats the check. The corrected behavior — warn on match, silent on mismatch — was implemented in Phase 3c-ii and is now the canonical contract. Same reconciliation style as the OpenPDF licensing note in `phase-2a-core-design.md`.
6. Uploaded state: show filename, size (formatted), uploaded-at timestamp, and a `Replace` button that re-opens the file picker. Replacing triggers the same `uploadAnnotation` call; the server handles replace ordering.
7. Error state: on HTTP error, show the error code+message from the `ErrorResponse` contract and render a `Retry` button.

### 3.2 Placement

In `src/app/(dashboard)/reviewer/review/[id]/page.tsx`, inside the left (60%) pane established by Phase 2A's two-pane layout, directly below `<DownloadPaperButton />`:

```tsx
<div className="space-y-4">
  <PaperPreviewPane paperId={paperId} />
  <DownloadPaperButton paperId={paperId} />
  <AnnotatedPaperUpload reviewId={reviewId} />
</div>
```

The right (40%) pane continues to host `<ReviewForm />` unchanged. This groups the "take the file out" and "put the file back" affordances together on the same side of the screen.

`ReviewForm.tsx` is NOT modified — the annotated upload is explicitly not a form field, so the existing form's submit flow stays intact and independent.

### 3.3 API client addition

```typescript
// src/lib/api/client.ts  (addition to reviewApi)
interface AnnotationUploadResult {
  annotationId: string;
  filename: string;
  sizeBytes: number;
  uploadedAt: string;
  matchesOriginal: boolean;
}

export const reviewApi = {
  // ... existing pending / completed / submit / decline ...

  uploadAnnotation: (
    reviewId: string,
    file: File,
    onProgress?: (pct: number) => void
  ): Promise<AnnotationUploadResult> => {
    const form = new FormData();
    form.append("file", file);
    return api
      .post<AnnotationUploadResult>(
        `/reviewer/reviews/${reviewId}/annotation`,
        form,
        {
          headers: { "Content-Type": "multipart/form-data" },
          onUploadProgress: (e) => {
            if (onProgress && e.total) {
              onProgress(Math.round((e.loaded / e.total) * 100));
            }
          },
        }
      )
      .then((r) => r.data);
  },
};
```

The existing axios interceptor attaches `Authorization: Bearer <jwt>` automatically. No additional request setup.

---

## 4. Authorization model

This flow is **reviewId-keyed**, not paperId-keyed. `ReviewAuthorizationService` (from Phase 2A) takes `(reviewerId, paperId)` and is explicitly documented as covering the read/download path. Routing annotation upload through it would either require (a) adding a paperId-keyed overload that looks up the review by paperId + reviewerId internally, which is a lossy inversion of the primary key we already have, or (b) letting the caller pre-resolve `paperId` from `reviewId`, which adds a DB round-trip and an error-handling branch for zero benefit.

Instead, the inline three-step check inside `ReviewAnnotationServiceImpl.uploadAnnotation`:
1. `reviewRepository.findById(reviewId)` — 404 on miss.
2. `review.getReviewer().getId().equals(callerId)` — 403 on mismatch (hides the existence of other reviews).
3. `review.getStatus() in (PENDING, IN_PROGRESS)` — 403 on anything else (DECLINED/EXPIRED/COMPLETED).

The Phase 2A precedent of "centralize through a helper" applies to the read path; for this write path the helper would be ceremonial and the inline check is authoritative and self-contained.

---

## 5. Cross-cutting concerns

### 5.1 Database migrations summary

| Version | Change | Reversibility |
|---|---|---|
| V9 | `ALTER TABLE papers ADD COLUMN manuscript_hash VARCHAR(64)` | Additive, nullable, safe to leave on rollback |
| V10 | `CREATE TABLE review_annotations` with FK + UNIQUE(review_id) | Additive, no impact on existing tables |

### 5.2 Dependency changes

Zero new backend dependencies. Zero new frontend dependencies.

- SHA-256 is in the JDK.
- `HexFormat` is JDK 17+; project runs Java 21.
- `ManuscriptValidator` is reused verbatim from 2A.
- `StorageService` is reused verbatim.
- Drag-and-drop is native HTML5 — no `react-dropzone`.
- Progress is axios-native `onUploadProgress`.

### 5.3 Impact on files

**New backend files**
- `src/main/resources/db/migration/V9__add_manuscript_hash_to_papers.sql`
- `src/main/resources/db/migration/V10__create_review_annotations.sql`
- `src/main/java/com/shodh/sanchayan/entity/ReviewAnnotation.java`
- `src/main/java/com/shodh/sanchayan/repository/ReviewAnnotationRepository.java`
- `src/main/java/com/shodh/sanchayan/service/ReviewAnnotationService.java`
- `src/main/java/com/shodh/sanchayan/service/impl/ReviewAnnotationServiceImpl.java`
- `src/main/java/com/shodh/sanchayan/service/dto/AnnotationUploadResult.java` (record)
- `src/main/java/com/shodh/sanchayan/util/ManuscriptHasher.java`

**Modified backend files**
- `src/main/java/com/shodh/sanchayan/entity/Paper.java` — add `manuscriptHash` column field.
- `src/main/java/com/shodh/sanchayan/service/impl/PaperServiceImpl.java` — inject `ManuscriptHasher`; in `submit` and `uploadRevision`, compute hash after `manuscriptValidator.validate(...)` and before `storageService.upload(...)`, then call `paper.setManuscriptHash(...)` alongside `setManuscriptSize`.
- `src/main/java/com/shodh/sanchayan/controller/api/ReviewController.java` — inject `ReviewAnnotationService`; add `POST /{reviewId}/annotation` handler forwarding `clientIp(request)` / `userAgent(request)` (helpers reused from 2B-i §3.1).

**New frontend files**
- `src/components/reviewer/AnnotatedPaperUpload.tsx`

**Modified frontend files**
- `src/app/(dashboard)/reviewer/review/[id]/page.tsx` — render `<AnnotatedPaperUpload reviewId={reviewId} />` in the left pane directly below `<DownloadPaperButton />`.
- `src/lib/api/client.ts` — add `reviewApi.uploadAnnotation(reviewId, file, onProgress?)`.

**Explicitly NOT modified**
- `ManuscriptValidator` (reused verbatim from 2A)
- `StorageService` / `LocalStorageServiceImpl` (reused verbatim)
- `ReviewAuthorizationService` (paperId-keyed; does not apply to reviewId-keyed annotation flow)
- `ReviewService` / `ReviewServiceImpl` (annotation upload is a separate concern from review state machine)
- `ReviewerPaperController` (annotation is reviewId-scoped, belongs on `ReviewController`)
- `PaperMapper` / `PaperDetailResponse` (no new author-visible field on paper detail)
- `ReviewForm.tsx` (unchanged — annotation upload lives in the left pane, not inside the form)
- `PaperPreviewPane.tsx` / `PaperPreviewError.tsx` (unchanged from 2A)
- `paper_access_audit` (no new row type in this phase)
- `reviewer_paper_agreements` (unrelated — 2B-ii)

### 5.4 Audit log interaction

Phase 2B-iii does **not** write to `paper_access_audit`. That table (introduced in 2B-i, V7) is scoped to reviewer read/download events and should stay focused on that concern; broadening it to uploads would muddy the `access_type` enum.

The correct future home for annotation-upload auditing is the pre-existing generic `audit_log` table via `auditService.log(...)`, which already records `PAPER_SUBMIT` and `PAPER_REVISION_UPLOAD` rows from the author flow. Adding a `REVIEW_ANNOTATION_UPLOAD` row-type there would be a handful of lines:

```java
// NOT built in Phase 2B-iii. Documented for future reference.
auditService.log(
    reviewerId,
    "REVIEW_ANNOTATION_UPLOAD",
    "REVIEW",
    reviewId.toString(),
    Map.of(
        "annotationId", saved.getId().toString(),
        "filename", saved.getOriginalFilename(),
        "sizeBytes", saved.getSizeBytes(),
        "matchesOriginal", saved.getMatchesOriginal()
    ),
    ipAddress,
    userAgent
);
```

The `ipAddress` and `userAgent` parameters on `ReviewAnnotationService.uploadAnnotation` are wired end-to-end (controller → service) for exactly this reason. They are currently unused by the service body. Cost today: one line in the controller to extract them via the 2B-i helpers. Cost of deferring the plumbing until the audit path is built: a signature change in service + controller + tests + any intervening callers. Cheap insurance now beats a cross-layer refactor later.

### 5.5 Risk register

| Risk | Description | Mitigation |
|---|---|---|
| E1 | Retroactive change to 2A's `PaperServiceImpl.submit` / `uploadRevision` introduces regression in author upload flow | Hash computation is additive (one helper call + one setter on the same `Paper` entity already being built); no existing field is moved or renamed; Phase 3 integration tests cover author submit + author revision paths unchanged; `manuscript_hash` column is nullable so failure to set it never breaks inserts |
| E2 | `storageService.delete(oldKey)` fails after new annotation row has been persisted — orphan file left on disk | Accepted as tolerable. Replace ordering (save new → update DB → delete old) guarantees the user's latest upload is durable and the DB row always points to a readable file. Orphaned blobs are a disk-usage concern only, cleanable by an offline sweep script in a later phase. Never reverse the ordering. |
| E3 | Reviewer uploads a 100 MB+ PDF; SHA-256 + magic-byte parse stalls request thread | `ManuscriptValidator` already enforces the multipart size cap inherited from 2A before any hashing runs; SHA-256 on the in-memory byte array is linear and negligible versus the upload time itself. If production traffic ever shows an abuse pattern, the mitigation is a one-line `spring.servlet.multipart.max-file-size` reduction in `application.yml` or a dedicated smaller cap inside `ManuscriptValidator` keyed on an "annotation" flag. Not built in Phase 2B-iii. |
| E4 | Legacy papers (submitted before V9) have `manuscript_hash = NULL`, so `matchesOriginal` is always `false` for reviews against those papers — reviewer may see the flag and assume tampering | Silent-skip is the committed behavior: when `paper.getManuscriptHash() == null`, the service sets `matchesOriginal = false` with no distinguishing signal. Frontend renders the flag as info-only ("Hash matches original: no") and never blocks the upload. Reviewer help text (Phase 3 copy work) should mention that the match indicator is only meaningful for papers submitted after the feature rollout. Accepted limitation of forward-only migration. |

---

## 6. Summary of committed decisions (D28–D41)

| # | Decision | Choice |
|---|---|---|
| D28 | Schema shape for annotation storage | New table `review_annotations` (1:1 optional with `reviews` via `UNIQUE(review_id)`), NOT columns on `reviews` |
| D29 | Flyway migration split | V9 adds `papers.manuscript_hash` (nullable), V10 creates `review_annotations` — one logical change per migration |
| D30 | Hash computation placement | `@Component ManuscriptHasher` called from `PaperServiceImpl.submit` + `uploadRevision`, after `manuscriptValidator.validate(...)` and before `storageService.upload(...)`, inside the existing `if (manuscript != null && !manuscript.isEmpty())` block |
| D31 | Hash algorithm + encoding | JDK `MessageDigest.getInstance("SHA-256")` + `HexFormat.of().formatHex(digest)` (lowercase hex, 64 chars), Java 21 native |
| D32 | Backfill policy | Forward-only. No data migration for pre-V9 papers. `manuscript_hash` stays NULL on legacy rows forever. |
| D33 | Service class | New `ReviewAnnotationService` / `ReviewAnnotationServiceImpl`, NOT methods added to `ReviewService` — matches 2A's separation of file-handling from state-machine services |
| D34 | Storage folder | `annotations` folder under `./uploads/`, key pattern `annotations/{reviewId}/{UUID}_{originalFilename}` via `storageService.upload("annotations/" + reviewId, originalFilename, file)` |
| D35 | Replace ordering | save new file → update DB row → delete old file. Never reversed. Orphan on delete failure is accepted. |
| D36 | Authorization | Inline 3-step check inside `ReviewAnnotationServiceImpl` (load by reviewId → `review.getReviewer().getId().equals(callerId)` → `review.getStatus() in (PENDING, IN_PROGRESS)`), NOT routed through `ReviewAuthorizationService` which is paperId-keyed |
| D37 | Controller placement | `POST /reviewer/reviews/{reviewId}/annotation` on existing `ReviewController` (URL base already matches; avoids splitting `/reviewer/reviews/*` across two files) |
| D38 | Hash mismatch response | Response body `matchesOriginal: boolean` only. Never an HTTP rejection. Null original hash → `false` silently. |
| D39 | Frontend — dropzone library | Native HTML5 `onDragEnter/Over/Leave/Drop`. No `react-dropzone`. Zero new frontend dependencies. |
| D40 | Frontend — component placement | `AnnotatedPaperUpload` rendered directly below `DownloadPaperButton` in the left (60%) pane of the two-pane reviewer review layout from Phase 2A |
| D41 | Audit integration | No `paper_access_audit` row for annotation upload in Phase 2B-iii. The 2B-i audit table stays focused on read/download access. `ipAddress` / `userAgent` are plumbed end-to-end but unused in the service body, reserved for a future phase that wires `auditService.log(...)` for `REVIEW_ANNOTATION_UPLOAD`. |

---

## 7. File inventory for Phase 3 implementation

**Backend — create (8)**
1. `src/main/resources/db/migration/V9__add_manuscript_hash_to_papers.sql`
2. `src/main/resources/db/migration/V10__create_review_annotations.sql`
3. `src/main/java/com/shodh/sanchayan/entity/ReviewAnnotation.java`
4. `src/main/java/com/shodh/sanchayan/repository/ReviewAnnotationRepository.java`
5. `src/main/java/com/shodh/sanchayan/service/ReviewAnnotationService.java`
6. `src/main/java/com/shodh/sanchayan/service/impl/ReviewAnnotationServiceImpl.java`
7. `src/main/java/com/shodh/sanchayan/service/dto/AnnotationUploadResult.java`
8. `src/main/java/com/shodh/sanchayan/util/ManuscriptHasher.java`

**Backend — modify (3)**
1. `src/main/java/com/shodh/sanchayan/entity/Paper.java`
2. `src/main/java/com/shodh/sanchayan/service/impl/PaperServiceImpl.java`
3. `src/main/java/com/shodh/sanchayan/controller/api/ReviewController.java`

**Frontend — create (1)**
1. `src/components/reviewer/AnnotatedPaperUpload.tsx`

**Frontend — modify (2)**
1. `src/app/(dashboard)/reviewer/review/[id]/page.tsx`
2. `src/lib/api/client.ts`

---

## 8. Cross-references

- **Phase 1 discovery** (`phase-1-discovery.md`) — Spring Boot 3.3.5 + Java 21 + Flyway V1..V6 baseline; no PDFBox/Tika; generic `audit_log` table via `auditService.log(...)` already supports `PAPER_SUBMIT` / `PAPER_REVISION_UPLOAD`.
- **Phase 2A core design** (`phase-2a-core-design.md`) — provides `ManuscriptValidator` (reused for annotation validation), `ForbiddenException` (reused in authorization check), and the two-pane reviewer review layout (left pane hosts the annotation upload).
- **Phase 2B-i audit & watermark design** (`phase-2b-i-audit-watermark-design.md`) — establishes `paper_access_audit` table (V7), the primitives-down audit logging pattern, and the `clientIp(HttpServletRequest)` / `userAgent(HttpServletRequest)` helpers reused by the annotation controller.
- **Phase 2B-ii agreement design** (`phase-2b-ii-agreement-design.md`) — establishes V8 (`reviewer_paper_agreements`) and the `saveAndFlush` idempotency pattern echoed here; Phase 2B-iii's V9+V10 follow the same single-concern-per-migration convention.
- **ADR — paper detail authorization** (`adr-paper-detail-authorization.md`) — unchanged by this phase; the single-gateway rule for `PaperDetailResponse` is not involved because annotation upload returns `AnnotationUploadResult`, not `PaperDetailResponse`.
