# File-Storage — Deferred Items Implementation Plan (TEMPORARY)

> **TEMPORARY working plan — delete once split into tickets/PRs.**
> Companion to `IMPLEMENTATION_PLAN_TEMP.txt` (the P2 remediation master plan);
> covers the sub-parts that plan's EXECUTION LOG marks DEFERRED / NOT DONE.
> Branch: `feat/file-storage-p2-remediation`. Package: `cf-gears-file-storage`
> (`-p cf-gears-file-storage`, lib name `file_storage`). All commits DCO-signed
> (`git commit -s`), never pushed to `main`.
>
> Written 2026-07-08 against HEAD `ca5061583` (ADR-0006 content-hash modes fully
> landed). **All `file:line` anchors below are re-verified against that HEAD**,
> not against the stale line numbers in `IMPLEMENTATION_PLAN_TEMP.txt`.
> Paths are relative to `gears/file-storage/file-storage/` unless they start
> with `docs/`, `testing/`, `tools/`, or `libs/`.

Verification toolchain for every slice (run inside `gears/file-storage/file-storage/`):

```bash
cargo build -p cf-gears-file-storage
cargo clippy -p cf-gears-file-storage --all-targets -- -D warnings
cargo test  -p cf-gears-file-storage        # s3 paths run against in-process s3s-fs
cargo fmt --check
# when routes/DTOs change: make openapi  (regenerates docs/api/api.json)
```

---

## 0. Recommended execution wave order

**Important re-scoping discovered during re-anchoring (vs the execution log):**

- **0.1's "streaming read-back on finalize" is ALREADY DONE** (landed with
  ADR-0006 work): `read_back_and_hash_streaming` at
  `src/domain/service/write.rs:103` is called from both `finalize_upload`
  (`write.rs:220-233`) and `finalize_upload_by_token` (`write.rs:710-723`),
  size/hash are re-derived from real bytes and the read-back values are
  persisted. **Only the "retire client-driven finalize" (internal-auth) half of
  0.1 remains**, and it is 🛑-gated.
- **1.10's single-part finalize MIME validation is ALREADY DONE**
  (`validate_and_resolve_mime` at `write.rs:37`, called at `write.rs:244` and
  `write.rs:734`). Remaining 1.10 scope = **multipart-complete MIME
  validation** (+ optional sidecar ingress sniff, tracked but not required).
- **2.8's orphan-file deletion is ALREADY DONE** (`abandoned_files_deleted`
  counter + `maybe_delete_orphaned_file`, `src/domain/cleanup.rs:147-151,
  308-345`). Remaining 2.8 scope = **the age-only pending-version selection
  that can kill an active multipart session's backing version**.
- **The E2E harness ALREADY EXISTS** (`testing/e2e/suites/file_storage/lifecycle/`
  spawns its own server+sidecar off `FS_E2E_BINARY`/`FS_SIDECAR_BINARY`;
  `tools/scripts/ci.py:484-493` sets both). Remaining E2E scope = the backlog
  of promised cases + a second-principal (`E2E_AUTH_TOKEN_USER_B`) fixture.

### Waves

| Wave | Items | Parallel-safe? | Gate |
|------|-------|----------------|------|
| **1** (executable now) | **1.6** (`/readyz`), **2.8** (multipart-aware sweep), **1.10** (multipart-complete MIME) | Yes — disjoint files (sidecar+backend trait / cleanup+version_repo / multipart_service) | none |
| **2** (executable now, serialized behind Wave 1 file-owners) | **1.11** (MIME+ETag in GET claims → sidecar headers) after 1.6; **3.3** (rich multipart-complete contract) after 1.10 | 1.11 ∥ 3.3 (disjoint); each serialized behind its Wave-1 sibling (shared `sidecar.rs` / `multipart_service.rs`+`handlers.rs`) | none |
| **3** (gated) | **3.4** (introspect/resume — after 3.3), **2.7** (content_id FK), **0.1** (internal-auth finalize) | 3.4 ∥ 2.7 ∥ 0.1 once unblocked | 🛑 3.4 ship-or-defer; 🛑 2.7 cross-backend FK policy; 🛑 0.1 auth-mechanism decision |
| **4** (last) | **3.6** (five FEATURE docs — after all behavior-changing items so docs describe final state), **E2E-A** (env-only cases), **E2E-B** (cross-user cases) | 3.6 ∥ E2E-A | 🛑 E2E-B needs `E2E_AUTH_TOKEN_USER_B` second-principal support in the e2e auth stub; E2E-A needs only a built binary (already automated in ci.py) |

### Risk / size per item

| Item | Effort | Correctness risk | One-line rationale |
|------|--------|------------------|--------------------|
| 0.1 (remaining) | M–L | **High** (auth-model change) | Adds a second auth factor to the s2s finalize/report-part routes; a wrong default bricks every upload in deployments where the sidecar isn't updated in lockstep. Gated on choosing the credential mechanism (toolkit internal_auth profiles are only partially wired platform-wide). |
| 1.6 | S | Low | Additive `/readyz` route + a new default-implemented `StorageBackend` readiness method; worst failure mode is a noisy 503 on a probe. |
| 1.10 (remaining) | M | Medium | Touches the multipart complete "point of no return" (after backend assembly, before finalize_version); a bug can strand assembled blobs (reclaimed by sweep, so bounded). |
| 1.11 (remaining) | M | Low–Medium | Claims schema grows two optional fields (serde-default keeps old tokens verifying); sidecar header emission is additive. Version-skew between control plane and sidecar must be tolerated (it is, by `#[serde(default)]`). |
| 2.7 (remaining) | M | **High** (schema) | Circular FK (`files.content_id → file_versions` RESTRICT vs `file_versions.file_id → files` CASCADE) + SQLite cannot `ALTER TABLE ADD CONSTRAINT` at all — per-backend divergence or table rebuild. Predicate guard already shipped, so the FK is belt-and-braces, not urgent. |
| 2.8 (remaining) | S–M | Low | One repo query gains a `NOT EXISTS` sub-select; failure mode is over-conservative sweeping (versions linger one extra tick), never data loss. |
| 3.3 | M | Medium | Breaking API contract change (204→200 with body) + new 409/precondition semantics; SDK/api.md/OpenAPI must move together. |
| 3.4 | M | Low | Read-only endpoint; main risk is leaking another principal's session state (mask as 404, same as complete does). Gated on ship-or-defer. |
| 3.6 | M–L | Negligible | Docs only; effort is in grounding five feature docs in real code. |
| E2E | M | Low (code) | Python tests only; risk is flakiness/env drift, not product correctness. Cross-user half gated on a second-token fixture. |

**Single most important cross-item risk:** items **1.10 → 3.3 → 3.4** all rewrite
the same multipart-complete chain (`src/domain/multipart_service.rs::complete_multipart_upload`,
`src/api/rest/handlers.rs::complete_multipart`, `src/api/rest/routes.rs`,
`src/api/rest/dto.rs`) and E2E's multipart lifecycle asserts its final shape.
They MUST be executed serially in that order (each rebased on the previous
slice's commit); launching them as parallel agents guarantees conflicts and/or
a 204-vs-200 contract regression.

---

## 1. Item 0.1 (remaining) — retire client-driven finalize (internal-auth on the s2s callback routes)

### Current state vs gap

The data-integrity half is closed: both finalize paths stream the blob back
from the backend and re-derive size/hash/MIME (`read_back_and_hash_streaming`,
`src/domain/service/write.rs:103-135`, used at `write.rs:220-251` and
`write.rs:710-741`); forged claims are rejected and read-back values are
persisted. What remains: `POST …/versions/{version_id}/finalize` and
`POST …/parts/{part_number}/report` are registered `.public()`
(`src/api/rest/routes.rs:49-67` and `:69-95`) with the signed upload token as
the *sole* authorization — and that token is handed to the client in plaintext
inside `upload_url` (minted by `sign_url`, `src/domain/service/mod.rs:170-203`,
returned via `UploadTicketDto`). So the *client* can still drive
finalize/report-part directly (it can no longer forge size/hash, but it can
finalize at a moment of its choosing, replay reports, and generally occupy a
trust position designed for the sidecar).

### Concrete approach

🛑 **Decision gate first — see below.** Assuming the interim gear-local shared
secret (recommended; the toolkit-wide `internal_auth` profiles are not
deployable here yet — see gate):

1. `src/config.rs` — add to `FileStorageConfig`:
   `pub finalize_internal_secret: Option<String>` (doc: when `Some`, the
   finalize/report-part routes additionally require the
   `x-fs-internal-token` header to match; `None` preserves today's
   token-only behavior) and
   `pub require_finalize_internal_secret: bool` (default `false`; mirrors the
   `require_signing_key_seed` pattern at `config.rs:63` — production profiles
   set it `true` so a missing secret fails startup instead of silently
   downgrading). Validate the pair in `FileStorageConfig::validate()`
   (`config.rs:216`).
2. `src/api/rest/handlers.rs` — in `finalize_version` (`handlers.rs:540-595`)
   and `report_multipart_part` (`handlers.rs:619+`): after token verification,
   if the configured secret is present, require header `x-fs-internal-token`
   and constant-time-compare (use `subtle::ConstantTimeEq` or
   `ring::constant_time::verify_slices_are_equal` — `ring` is already a dep via
   signed_url) against the configured value; mismatch/missing →
   `DomainError::token_invalid("finalize requires internal credential")` (403).
   Plumb the secret via an axum `Extension<Arc<FinalizeAuth>>` registered in
   `routes.rs` next to the existing `Extension(verifier)` wiring (bottom of
   `build_routes`).
3. `src/bin/sidecar.rs` — new env `FS_SIDECAR_INTERNAL_TOKEN` (document in the
   module header env list, `sidecar.rs:9-40`); attach it as
   `x-fs-internal-token` on the finalize callback (`call_finalize_callback`,
   ~`sidecar.rs:522-561`) and the report-part callback (~`sidecar.rs:606-654`).
   Absent env = header not sent (works against a control plane with the check
   disabled).
4. `src/gear.rs` — thread config → service/router state.
5. Docs: `docs/ADR/0003-…-sidecar-data-plane.md` (trust-model section) and
   `docs/api.md` finalize/report rows gain "requires internal credential when
   configured". Note in both that the upload token remaining client-visible is
   now harmless for these routes.
6. Migration path note (in ADR + config docs): deploy order is
   control-plane-with-`None` → sidecars with env → flip
   `require_finalize_internal_secret=true`.

### Tests / verification

- `tests/finalize_test.rs` (extend; it already covers the token path):
  `finalize_with_internal_secret_required_rejects_missing_header`,
  `finalize_with_internal_secret_required_accepts_matching_header`,
  `report_part_with_internal_secret_required_rejects_missing_header`.
  Exercise through the axum handler (Router::oneshot over the gear router, or
  direct handler call with hand-built `HeaderMap` — mirror how
  `finalize_test.rs` builds `Claims` today).
- `src/config_tests.rs`: `require_finalize_internal_secret_without_secret_fails_validate`.
- Sidecar side (`src/bin/sidecar_tests.rs`): assert the callback request
  builder includes the header when the env-derived field is set (the existing
  tests already stub the control-plane callback with a local `TcpListener` —
  capture and assert the header).
- E2E (after the E2E item lands): extend the lifecycle flow to run the
  sidecar+server with the secret set and assert a client-driven finalize
  (reusing the plaintext token but no secret) gets 403 while the real
  sidecar-driven flow still completes.

### Dependencies & ordering

Independent of every other item file-wise except `handlers.rs`
(shared with 3.3/3.4 — schedule 0.1 after the 3.3/3.4 chain or accept a small
rebase). Blocked solely by the 🛑 decision.

### Sonnet-agent breakdown

- **Agent 0.1-A** — "Internal-credential gate on s2s finalize/report routes + sidecar env plumbing + tests" (one commit; includes config, handlers, sidecar, ADR/api.md notes).

### Decision gates / blockers

- 🛑 **Auth mechanism**: `libs/toolkit-security/src/internal_auth.rs` exists but
  Profile 1 is `InternalCredential::None` (in-process trust — useless here:
  the sidecar is a separate OS process), Profile 2 (`BootstrapToken`) is
  **struct-only, validation deferred**, and Profile 3 requires K8s
  `TokenReview` wiring that lives in bootstrap layers not yet wired into this
  gear. Team must choose: (a) the interim gear-local shared secret above
  (recommended, forward-compatible — swap the comparator for
  `InternalAuthenticator` when the platform wiring lands), or (b) wait for
  platform internal-auth (defers this item indefinitely). Also confirm the
  rollout stance for the `require_…` default (breaking for sidecars not
  redeployed with the env var).

---

## 2. Item 1.6 (remaining) — sidecar `/readyz`

### Current state vs gap

`/healthz` is live: route at `src/bin/sidecar.rs:265`, handler at
`sidecar.rs:309-311`, and the router is testable via `build_router`
(`sidecar.rs:245-275`). `/readyz` was skipped because `SidecarState.backends`
is a `BackendRegistry` of `Arc<dyn StorageBackend>` with no cheap readiness
hook (see the doc comment at `sidecar.rs:303-308`). K8s readiness probes
therefore have nothing that reflects backend availability (e.g. local-fs root
unmounted, S3 endpoint unreachable).

### Concrete approach

1. `src/infra/backend/mod.rs` — add to the `StorageBackend` trait:
   ```rust
   /// Cheap readiness probe. Default: always ready (in-memory & any backend
   /// with no external dependency).
   async fn is_ready(&self) -> Result<(), DomainError> { Ok(()) }
   ```
2. Implementations:
   - `src/infra/backend/local_fs.rs`: `tokio::fs::metadata(&self.root)` and
     require `is_dir()`; map failure to a `DomainError::internal`-style error
     naming the backend id (whatever constructor the error type offers —
     reuse the same variant `LocalFsBackend` I/O paths already map to).
   - `src/infra/backend/s3.rs`: a minimal authenticated round-trip —
     `self.exists("<readyz-probe>")`-equivalent (HEAD of a well-known
     nonexistent key; treat "NotFound" as ready, transport/5xx/auth errors as
     not-ready). Reuses the existing rusty-s3 request plumbing; do NOT add a
     new S3 API surface.
   - `in_memory.rs`: default impl.
3. `src/infra/backend/mod.rs` (`BackendRegistry`) — if not already present,
   add `pub fn iter(&self) -> impl Iterator<Item = (&str, &Arc<dyn StorageBackend>)>`
   (or `all()`), needed by the probe handler.
4. `src/bin/sidecar.rs` — add
   `.route("/readyz", get(readyz))` next to `sidecar.rs:265`;
   handler: iterate `state.backends`, `join_all` the `is_ready()` futures with
   a short `tokio::time::timeout` (e.g. 2s) each; all ok → `200 "ready"`, any
   failure/timeout → `503` with a body listing failing backend ids only
   (`"not ready: s3-primary"`) — never error details (consistent with 1.11's
   no-leak stance). Update the module docs (`sidecar.rs:262-264` and
   `303-308`) to drop the "skipped" rationale.

### Tests / verification

- `src/bin/sidecar_tests.rs` (uses `build_router` oneshot):
  - `sidecar_readyz_returns_200_when_backends_ready` (local-fs on an existing
    `tempfile::tempdir`).
  - `sidecar_readyz_returns_503_when_backend_root_missing` (local-fs root =
    a path inside a tempdir that was deleted; assert 503 and that the body
    names `local-fs` but contains no OS error string).
- `src/infra/backend/backend_tests.rs`: `s3_is_ready_ok_against_s3s_fs` (green
  against the in-process s3s-fs fixture the existing s3 tests use) and
  `s3_is_ready_err_against_closed_port` (endpoint at an unbound localhost
  port).
- Done-check: `cargo test -p cf-gears-file-storage sidecar_readyz` green;
  `rg -n '"/readyz"' src/bin/sidecar.rs` → one match.

### Dependencies & ordering

None. Touches `sidecar.rs`/`sidecar_tests.rs` — **serialize with 1.11** (1.6
first; it's smaller and 1.11's tests build on the same test module).

### Sonnet-agent breakdown

- **Agent 1.6-A** — "StorageBackend::is_ready + sidecar /readyz route + probe tests" (one commit).

### Decision gates / blockers

None hard. One soft call the agent can take as specified: S3 readiness does a
real network round-trip per probe (k8s default ~10s period — negligible load);
if the team objects, local-fs-only checking is the fallback (note it in the
handler doc comment).

---

## 3. Item 1.10 (remaining) — MIME validation on the multipart-complete path

### Current state vs gap

Single-part is covered: both finalize paths sniff the read-back prefix and
persist the validated MIME (`validate_and_resolve_mime`,
`src/domain/service/write.rs:37-51`, called at `write.rs:244` / `write.rs:734`;
sniffing logic in `src/infra/content/mime.rs:19-26`). Multipart is not:
`complete_multipart_upload` (`src/domain/multipart_service.rs:566-790`) uses
`session.declared_mime` for the policy size check only (`:652-668`), never
sniffs any bytes, and its `finalize_version` call (`:725-737`, via the
`MultipartStore` port in `src/domain/ports.rs`) has no validated-MIME
parameter — so a policy restricting MIME types is bypassable by declaring an
allowed type at initiate and multipart-uploading anything. The schema even
anticipates the fix: `multipart_uploads.mime_validated` exists and is set
`false` at initiate (`src/infra/storage/repo/multipart_repo.rs:62`) and never
flipped.

### Concrete approach

Validate **post-assembly, pre-finalize** (backend-agnostic; S3 part objects
are not independently readable pre-complete, so ingress-time sniffing at the
sidecar stays a separately-tracked hardening, not part of this slice):

1. `src/domain/multipart_service.rs::complete_multipart_upload` — after
   `backend.complete_multipart(...)` succeeds (`:704-710`) and before the
   `finalize_version` call (`:725`):
   ```rust
   // Sniff the assembled object's leading bytes (same budget as the
   // single-part path, write.rs::MIME_SNIFF_PREFIX_BYTES = 8 KiB).
   let prefix = backend
       .get_range(&backend_path, file_storage_sdk::ByteRange::/* 0..8191 inclusive form used by range.rs */)
       .await?;
   let validated_mime = /* shared helper, see step 2 */(&session.declared_mime, &prefix)?;
   ```
   On mismatch the complete fails with `DomainError::mime_mismatch` **before**
   any DB finalize — the assembled blob becomes an orphan reclaimed by the
   sweep (same recovery story as the existing `!finalized` branch at
   `:738-746`; say so in a comment). Re-run the policy ceiling against the
   validated MIME exactly like the single-part path does
   (`enforce_size_ceiling_for_validated_mime`, `write.rs:53-81`).
2. Share, don't duplicate: move `validate_and_resolve_mime` and
   `enforce_size_ceiling_for_validated_mime` (+ `MIME_SNIFF_PREFIX_BYTES`)
   from `src/domain/service/write.rs:37-83` to a `pub(crate)` home both
   services can use — `src/infra/content/mime.rs` (for the resolve helper) or
   a small `src/domain/service/mime_guard.rs`; keep call sites in `write.rs`
   compiling unchanged apart from the import.
3. Thread the validated MIME + `mime_validated` flag into persistence:
   - `src/domain/ports.rs` (`MultipartStore::finalize_version`) and its impl
     in `src/infra/storage/store/` gain `validated_mime: Option<String>` —
     mirroring what the main `Store::finalize_version` already accepts for the
     single-part path (see the `Some(validated_mime)` argument at
     `write.rs:276`).
   - `store.complete_multipart_upload(upload_id, audit)` (`:758-761`) — set
     `mime_validated = true` in the same UPDATE (repo:
     `src/infra/storage/repo/multipart_repo.rs`, the state-transition method).
4. No new config knobs, no migration (columns exist).

### Tests / verification

- `tests/multipart_test.rs` (mirrors existing helpers/backends):
  - `multipart_complete_rejects_content_not_matching_declared_mime` — initiate
    with `declared_mime = "image/png"`, upload parts whose assembled leading
    bytes are a JPEG/zip signature, complete → assert `DomainError::MimeMismatch`
    (or its mapped 400/422 through the error-mapping table), version row still
    `pending`, session still `in_progress` or aborted per the chosen semantics
    (assert whichever the code does — the version must NOT be `available`).
  - `multipart_complete_persists_validated_mime_and_flag` — positive control:
    unrecognizable bytes (plain text) under `text/plain` complete fine;
    assert `file_versions.mime_type` equals the validated type and
    `multipart_uploads.mime_validated = true` via direct entity read.
- If a new `DomainError` variant is introduced (it shouldn't be — reuse
  `mime_mismatch`), `tests/error_mapping_test.rs` forces a row (compile error
  otherwise) — verify it stays green regardless.
- E2E backlog entry (item E2E): `test_upload_content_mime_mismatch_is_rejected`
  covers the single-part path; add a multipart variant only if the multipart
  lifecycle case (0.2's `test_multipart_full_lifecycle_against_real_sidecar`)
  lands first.

### Dependencies & ordering

No blockers. **Serialize before 3.3** (same function/file). The helper move in
step 2 touches `write.rs` — trivial, no semantic conflict with anything else.

### Sonnet-agent breakdown

- **Agent 1.10-A** — "Multipart-complete MIME sniff + validated-mime persistence + mime_validated flag + tests" (one commit).

### Decision gates / blockers

None. (The sidecar ingress-sniff (option a) remains tracked as a follow-up
hardening; do not bundle it here — it needs a Claims change and coordinates
with 1.11's Claims change if ever done.)

---

## 4. Item 1.11 (remaining) — real MIME + ETag on sidecar downloads

### Current state vs gap

Range/status hardening is done (`download` at `src/bin/sidecar.rs:996-1055`
distinguishes 404/416/500; `Content-Range` on every 206 and on 416,
`download_range` `:1060-1109`). But every download response hardcodes
`Content-Type: application/octet-stream` (`FALLBACK_CONTENT_TYPE`,
`sidecar.rs:975`, used at `:1096` and `:1128`) and **omits `ETag`**
(`sidecar.rs:993-995` documents why): the signed `Claims`
(`src/infra/signed_url/mod.rs:97-122`) carry no MIME/ETag, and the sidecar has
no DB access. The control plane has both values in hand when minting the GET
URL: `download_url` (`src/domain/service/read_ops.rs:98-137`) reads
`version.mime_type` and computes `etag::content_etag(file_id, target)`
(`src/domain/etag.rs:24`) for the ticket — it just doesn't put them in the
token (`sign_url`, `src/domain/service/mod.rs:170-203`).

### Concrete approach

1. `src/infra/signed_url/mod.rs` — extend `Claims`:
   ```rust
   /// Stored MIME of the version (GET tokens only). `#[serde(default)]`
   /// keeps verification tolerant of tokens minted before this field.
   #[serde(default, skip_serializing_if = "String::is_empty")]
   pub content_type: String,
   /// Opaque content ETag of the (file, version) pair (GET tokens only).
   #[serde(default, skip_serializing_if = "String::is_empty")]
   pub etag: String,
   ```
   (Same tolerance pattern as `request_id` (`mod.rs:112-121`) and
   `backend_handle` (`mod.rs:90-92`) — old sidecars ignore the new fields, new
   sidecars tolerate old tokens.)
2. `src/domain/service/mod.rs::sign_url` (`:170-203`) — add a
   `download_meta: Option<(String, String)>` parameter (`(content_type, etag)`)
   or a tiny `DownloadMeta` struct; populate the new claims only for
   `Op::Get`. Update the non-GET call sites (presign in
   `src/domain/service/create.rs:316+`, multipart part URL minting in
   `multipart_service.rs`) to pass `None`.
3. `src/domain/service/read_ops.rs::download_url` — pass
   `version.mime_type.clone()` + `etag::content_etag(file_id, target)` (the
   same value already returned in `DownloadTicket.etag`, `:132-136` — one
   source of truth) through `build_download_url` → `sign_url`.
4. `src/bin/sidecar.rs` — in `download_range` (`:1088-1099`) and
   `download_whole` (`:1124-1131`): set `Content-Type` from
   `claims.content_type` when non-empty (fallback stays
   `FALLBACK_CONTENT_TYPE`; validate with `HeaderValue::from_str`, fall back
   on error) and `ETag: "<claims.etag>"` (quoted strong validator) when
   non-empty. Pass the needed claims fields into the two helpers (change their
   signatures to take `&Claims` or the two strings). Update the stale
   rationale comments at `:966-975` and `:986-995`.
   *Optional stretch (include only if trivial)*: `If-None-Match` matching the
   ETag → `304` with no body. If skipped, note it in the doc comment.
5. `docs/api.md` download section + `docs/ADR/0004` (token schema description):
   document the two new claims fields.

### Tests / verification

- `src/bin/sidecar_tests.rs` (existing token-minting helpers):
  - `download_sets_content_type_and_etag_from_claims` — mint a GET token with
    `content_type = "image/png"`, `etag = "abc123"`; assert 200 response has
    `Content-Type: image/png` and `ETag: "abc123"`.
  - `download_range_sets_content_type_and_etag_from_claims` — same on a 206.
  - `download_without_meta_claims_falls_back_to_octet_stream_and_no_etag` —
    token minted without the fields (old-token compatibility).
- `src/infra/signed_url/signed_url_tests.rs`: round-trip a `Claims` with and
  without the new fields (old-JSON deserialization tolerance).
- Service side (`src/domain/service/service_tests.rs` or `tests/service_test.rs`):
  `download_url_token_carries_version_mime_and_etag` — decode the minted
  token's payload (base64url JSON before the `.`) and assert the fields equal
  the version's stored MIME and `content_etag(file_id, version_id)`.
- E2E: piggyback on the existing lifecycle download step
  (`testing/e2e/suites/file_storage/lifecycle/test_file_storage_lifecycle.py::test_localfs_single_part_full_lifecycle`):
  assert the download response's `Content-Type` equals the created file's MIME
  and `ETag` is present (part of item E2E, case list below).

### Dependencies & ordering

After **1.6** (shared `sidecar.rs`/`sidecar_tests.rs`). Independent of the
multipart chain (different files except a one-line call-site touch in
`multipart_service.rs` for the `sign_url` signature — coordinate by running
after 1.10 has landed or accept the trivial rebase).

### Sonnet-agent breakdown

- **Agent 1.11-A** — "Thread content_type+etag through GET Claims; sidecar emits real Content-Type/ETag; compat + header tests" (one commit).

### Decision gates / blockers

None. (Version-skew is handled by serde defaults both directions; call it out
in the commit message.)

---

## 5. Item 2.7 (remaining) — `files.content_id → file_versions(version_id)` FK (`ON DELETE RESTRICT`)

> **DECISION (2026-07-08): DEFERRED — not implemented this round.**
> Rationale: the FK is Postgres-only (SQLite cannot `ALTER TABLE … ADD
> CONSTRAINT`), and this gear has **no Postgres test harness** — every
> `tests/*.rs` runs on `sqlite://…?mode=rwc`, `Cargo.toml` pulls no
> postgres/testcontainers dep, `cfs.yml` starts no services, and `ci.py` runs
> only `cargo test` + a SQLite integration step. The plan's own core proofs
> (`content_id_fk_blocks_dangling_update`, `delete_file_cascade_still_works…`,
> the circular `CASCADE`↔`RESTRICT` interaction it says "MUST be locked in by
> an integration test against a real flow") are therefore **unrunnable here**,
> and shipping an unverified circular-FK migration into production is exactly
> the tricky, untestable transactional change to avoid. The **already-shipped
> predicate guard** (`VersionRepo::delete` refuses `is_current = true`,
> `version_repo.rs:301-321`, + `delete_version`'s 0-rows→conflict) remains the
> active, SQLite-tested protection. Re-open when a Postgres-backed test
> harness exists (then the FK migration + `23503`→409 mapping + cascade test
> below can be implemented and actually verified). See
> [[project_file_storage_tests_sqlite_only]].

### Current state vs gap

The minimal race fix shipped: `VersionRepo::delete` refuses to delete a
current version at the DB predicate level
(`Column::IsCurrent.eq(false)`, `src/infra/storage/repo/version_repo.rs:301-321`,
rationale comment `:290-300`), and `delete_version`
(`src/domain/service/read_ops.rs:289+`) treats 0-rows-affected as
conflict/not-found. What's missing is the *structural* guarantee: `files.content_id`
has no FK (`src/infra/storage/migrations/m20260624_000001_p1_initial.rs` —
Postgres DDL line ~40, SQLite DDL line ~95), so a future code path (or manual
SQL) can still dangle the pointer. The FK's prerequisite landed with ADR-0006:
`file_versions_version_id_unique_idx` — a unique index on
`file_versions(version_id)` alone
(`m20260707_000001_content_hash_modes.rs:44` (pg) / `:62` (sqlite)) — created
*specifically* "for the single-column FK" (`:13`).

### Concrete approach

🛑 **Decision gate first** (below). Assuming "Postgres-only FK, SQLite keeps the
predicate guard" (recommended):

1. New migration `src/infra/storage/migrations/m20260708_000001_files_content_id_fk.rs`
   (register in `migrations/mod.rs`), raw-SQL per backend like every existing
   migration in this gear:
   - **Postgres UP**:
     ```sql
     ALTER TABLE files
       ADD CONSTRAINT files_content_id_fk
       FOREIGN KEY (content_id) REFERENCES file_versions (version_id)
       ON DELETE RESTRICT;
     ```
     Pre-clean first in the same UP (defensive, mirrors the dedup/backfill
     style of `m20260706_000003_policies_unique_scope`):
     `UPDATE files SET content_id = NULL WHERE content_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM file_versions v WHERE v.version_id = files.content_id);`
   - **Postgres DOWN**: `ALTER TABLE files DROP CONSTRAINT IF EXISTS files_content_id_fk;`
   - **SQLite UP/DOWN**: no-op with a loud doc comment: SQLite cannot
     `ALTER TABLE … ADD CONSTRAINT`; the `is_current = false` delete predicate
     (`version_repo.rs:301-321`) remains the sole guard there. (SQLite is the
     test/dev backend; production is Postgres.)
2. **Circular-FK note to encode in the migration's doc comment and prove by
   test**: `file_versions.file_id → files ON DELETE CASCADE` (p1_initial) plus
   this new `files.content_id → file_versions ON DELETE RESTRICT` forms a
   cycle. Deleting a `files` row removes the referencing row *first*, so the
   cascade's subsequent `file_versions` deletes see no remaining referencing
   `files` row and RESTRICT does not fire. This is expected Postgres behavior
   but MUST be locked in by an integration test against a real flow
   (`delete_file`), because it is exactly the "cross-backend verification"
   this item was deferred for.
3. Audit code paths that would now surface an FK error and map it cleanly:
   - `bind` (`src/domain/service/write.rs:319+`): binding to a
     concurrently-deleted version now fails at COMMIT with a DB FK error on
     Postgres → ensure `db_err` maps it to a 409/conflict, not a 500 (add a
     mapping if the FK violation currently maps to internal; the SQLSTATE is
     `23503`).
   - The sweep's `delete_version` / `delete_if_status` paths only ever delete
     `pending`/non-current versions, which `content_id` never references
     (bind requires `Available`) — verify, don't change.

### Tests / verification

- `tests/migration_test.rs` runs on `sqlite::memory:` (`migration_test.rs:32`),
  which **cannot exercise the FK** — so add the up/down/up cycle there (proves
  the no-op SQLite arms don't break the chain) *and* gate the real assertions
  behind the Postgres-backed test setup **iff the repo has one** (check
  `tests/` and CI for a `DATABASE_URL`-style Postgres harness; if none exists,
  the Postgres proof moves to the done-check below). Cases:
  - `content_id_fk_blocks_dangling_update` (pg only): raw `UPDATE files SET
    content_id = '<random uuid>'` → FK error.
  - `delete_file_cascade_still_works_with_content_id_fk` (pg only): create
    file + finalized bound version, `delete_file`, assert both rows gone.
  - `bind_to_deleted_version_fails_cleanly` — service-level test asserting a
    conflict-shaped error, valid on both backends (on SQLite it fails via the
    existing version-exists check; on pg additionally via FK).
- Done-check if no Postgres test harness exists: a documented manual check in
  the migration doc comment (`psql` transcript in the PR description) +
  `cargo test -p cf-gears-file-storage --test migration_test` green.

### Dependencies & ordering

Independent of all other items (new migration file + possibly `db_err`
mapping). Can run any time once the gate clears.

### Sonnet-agent breakdown

- **Agent 2.7-A** — "Migration m20260708_000001: files.content_id FK (pg) + cascade/bind race tests + FK-violation error mapping" (one commit).

### Decision gates / blockers

- 🛑 **Cross-backend policy**: accept a per-backend schema divergence
  (FK on Postgres, predicate-guard-only on SQLite)? The alternative — SQLite
  12-step table rebuild of `files` inside the migration — is high-risk and
  low-value for a dev/test backend. Also confirm whether any deployment
  actually runs SQLite in production (if yes, the no-op arm is not
  acceptable). Second confirmation: is there a Postgres-backed test harness
  in CI, or is manual verification acceptable as the done-check?

---

## 6. Item 2.8 (remaining) — sweep must not reclaim a live multipart session's backing version

### Current state vs gap

The sweep selects abandoned pending versions **by age and status alone**:
`Store::list_abandoned_pending_versions`
(`src/infra/storage/store/lifecycle.rs:66-75`) →
`VersionRepo::list_pending_older_than`
(`src/infra/storage/repo/version_repo.rs:359-378`) filters only
`status = 'pending' AND created_at < older_than`. A multipart upload keeps its
backing version `pending` for the whole session (TTL = `url_ttl_secs`,
`src/domain/multipart_service.rs:393-394`); if the session outlives
`orphan_grace_secs` (default 3600, `src/config.rs:79`) — long upload, big
file, generous URL TTL — `sweep_abandoned_pending` (`src/domain/cleanup.rs:182-214`)
deletes the version out from under the in-progress session, and the eventual
`complete` fails at `finalize_version` (`multipart_service.rs:738-746`),
losing the entire upload's work. (`delete_if_status` protection at
`version_repo.rs:332+` only guards the pending→available flip race, not this.)

### Concrete approach

1. `src/infra/storage/repo/version_repo.rs::list_pending_older_than` — add a
   `NOT EXISTS` guard against live sessions. SeaORM shape (matching the repo's
   existing `Condition::all()` style):
   ```rust
   use sea_orm::sea_query::{Expr, Query};
   use crate::infra::storage::entity::multipart_upload;
   // ... inside the filter:
   .add(
       Column::VersionId.not_in_subquery(
           Query::select()
               .column(multipart_upload::Column::VersionId)
               .from(multipart_upload::Entity)
               .and_where(multipart_upload::Column::State.eq("in_progress"))
               .and_where(multipart_upload::Column::ExpiresAt.gt(now))
               .to_owned(),
       ),
   )
   ```
   New parameter `now: OffsetDateTime` (signature change; the expired-session
   case must stay sweepable — an `in_progress` session whose `expires_at`
   already passed is handled by the *next* sweep step,
   `sweep_expired_multipart` (`cleanup.rs:154`), which aborts it; its version
   then becomes reclaimable on a later tick).
2. `src/infra/storage/store/lifecycle.rs:66-75` and the `CleanupStore` port
   (`src/domain/ports.rs:37`) + trait impl (`store/traits.rs:19`) — thread
   `now` through (the engine already has `now` in `run_sweep`,
   `cleanup.rs:142`).
3. `src/domain/cleanup.rs::sweep_abandoned_pending` (`:182`) — pass `now`;
   update the doc comment to state the new invariant ("a pending version
   referenced by a live in_progress multipart session is never selected,
   regardless of age").
4. `use sea_orm state note`: `multipart_uploads.version_id` has no index —
   the subquery scans; acceptable at sweep cadence (`sweep_interval_secs`),
   but add `CREATE INDEX IF NOT EXISTS multipart_uploads_version_id_idx ON
   multipart_uploads (version_id, state)` **only if** a migration is already
   being cut for 2.7 in the same PR window; otherwise skip (avoid a migration
   for an index the table sizes don't justify yet — note this in the commit).

### Tests / verification

- `tests/cleanup_test.rs` (existing helpers age rows via direct entity
  updates):
  - `sweep_skips_pending_version_of_active_multipart_session` — create file,
    `initiate_multipart_upload`, backdate the pending version's `created_at`
    past the grace cutoff (leave the session's `expires_at` in the future),
    run `run_sweep`; assert version row + session row both survive and
    `abandoned_pending_deleted == 0`.
  - `sweep_reclaims_version_after_session_expires` — same setup but also
    backdate `expires_at`; first sweep aborts the session
    (`expired_multipart_aborted == 1`), and the pending version is reclaimed
    (same sweep or a second `run_sweep` call — assert per actual engine
    behavior, both orders acceptable, the end state is: no version row).
  - Existing tests (`sweep_deletes_abandoned_zero_version_file`, etc.) stay
    green — the new predicate must not affect single-part pending versions
    (they have no session row).

### Dependencies & ordering

Independent; parallel-safe with 1.6 and 1.10 (touches
`version_repo.rs`/`lifecycle.rs`/`ports.rs`/`cleanup.rs` — none shared with
those items).

### Sonnet-agent breakdown

- **Agent 2.8-A** — "Multipart-session-aware abandoned-pending sweep predicate + cleanup tests" (one commit).

### Decision gates / blockers

None.

---

## 7. Item 3.3 (remaining) — richer multipart `complete` contract

### Current state vs gap

`complete` returns `204 No Content`: handler `complete_multipart`
(`src/api/rest/handlers.rs:451-461`) discards the service result and returns
`no_content()`; route registers `StatusCode::NO_CONTENT`
(`src/api/rest/routes.rs`, the block ending `json_response(StatusCode::NO_CONTENT,
"Completed")` in the multipart section ~`:469-487`). The service already
*computes* everything the doc-promised rich response needs —
`session.version_id`, `total_size` (`multipart_service.rs:631`), the ADR-0006
composite `root` hash + `manifest` + `part_count` (`:704-714`) — and throws it
away (`Result<(), DomainError>`, `:571`). There is no `If-Match` support, and
a missing part surfaces only as an opaque size-mismatch 409 (`:639-647`) with
no missing-part list.

### Concrete approach

1. `src/domain/multipart_service.rs` — change
   `complete_multipart_upload(&self, ctx, file_id, upload_id)` to
   `(&self, ctx, file_id, upload_id, if_match: Option<&str>) ->
   Result<CompletedMultipartUpload, DomainError>` with
   ```rust
   pub struct CompletedMultipartUpload {
       pub version_id: Uuid,
       pub size: i64,
       pub hash_algorithm: &'static str,        // "SHA-256"
       pub content_hash: Vec<u8>,               // ADR-0006 root
       pub hash_mode: HashMode,                 // MultipartCompositeSha256
       pub part_count: i32,
       pub manifest: String,                    // wire-format manifest text
   }
   ```
   (domain type in `src/domain/multipart.rs` next to `MultipartUploadSession`).
   All values are already in scope at `:711-714`; return them instead of `()`.
   Include `manifest` because the client-side re-verification flow
   (`docs/features/content-hash-modes.md` §"Client-Side Manifest
   Re-Verification") needs it; note in the field doc that at ~90 B/part it is
   ~1 MB at the 10k-part ceiling — acceptable for a one-shot response.
2. **If-Match semantics** (mirror `bind`, `handlers.rs:129-130`): when
   `Some`, compare against the file's current content ETag
   (`etag::etag_for(&file)`, `src/domain/etag.rs:32`) right after the
   `require_file`/authorize block (`:573-578`); mismatch →
   `DomainError::precondition_failed(...)` (`src/domain/error.rs:135`), which
   the platform maps via `FileResourceError::failed_precondition`
   (`src/api/rest/error.rs:69-71`) — reuse exactly, do not invent a new
   variant. `None` = unconditional (backward compatible; `*` handled the same
   way `bind` handles it).
3. **409-with-missing-parts**: before the size check at `:639`, compute the
   expected part set from the plan — `expected_count =
   declared_size.div_ceil(part_size)` (both on `session`,
   `src/domain/multipart.rs:56-58`) — and diff against reported
   `parts` numbers. If non-empty, return a new
   `DomainError::MultipartPartsMissing { upload_id, missing: Vec<u32> }`
   (constructor `multipart_parts_missing`), mapped to 409 in
   `src/api/rest/error.rs` with the missing part numbers in the error detail
   (follow the existing `FileResourceError` builder pattern; message like
   `"multipart upload {id}: parts missing: [2, 5]"`). Adding the variant
   **forces** a row in `tests/error_mapping_test.rs` (exhaustive match) — add
   it. Keep the size-mismatch check after it as the residual guard.
4. `src/api/rest/dto.rs` — new response DTO:
   ```rust
   #[toolkit_macros::api_dto(response)]
   pub struct MultipartCompleteDto {
       pub version_id: Uuid,
       pub size: i64,
       pub hash_algorithm: String,
       pub content_hash: String,   // hex
       pub hash_mode: String,      // "multipart-composite-sha256"
       pub part_count: i32,
       pub manifest: String,
   }
   ```
   (field spellings must match `VersionDto`'s existing `hash_mode`/`part_count`
   at `dto.rs:150-170`.)
5. `src/api/rest/handlers.rs::complete_multipart` (`:451-461`) — extract
   `if-match` via the same `header_str(&headers, "if-match")` helper as `bind`
   (`:129`), pass through, map domain result → DTO, return
   `Ok(Json(dto))` (200).
6. `src/api/rest/routes.rs` — complete registration: swap
   `json_response(StatusCode::NO_CONTENT, "Completed")` for
   `json_response_with_schema::<dto::MultipartCompleteDto>(openapi,
   StatusCode::OK, "Completed — version id, size, composite hash, manifest")`,
   and add the same precondition-failure error registration `bind` uses (match
   `bind`'s route block exactly — canonical-status question was settled by 2.5,
   follow whatever `bind` registers today).
7. Regenerate OpenAPI (`make openapi` → `docs/api/api.json`); update
   `docs/features/multipart-coordinator.md` (complete flow + response example
   + tick the acceptance-criteria lines this closes) and `docs/api.md`'s
   multipart rows. The response example must include
   `hash_mode`/`part_count`/`manifest` (ADR-0006 shape — this is the
   "plan taking the new shape into account" requirement).
8. **Compat callout in the commit message**: 204→200 is a breaking change for
   any client asserting 204. Repo-internal callers: e2e lifecycle tests (s3
   variant asserts complete status — update
   `testing/e2e/suites/file_storage/lifecycle_s3/test_file_storage_s3_lifecycle.py`
   if it checks 204) and `docs/` examples. Grep: `rg -n "complete" testing/e2e/suites/file_storage`.

### Tests / verification

- `tests/multipart_test.rs`:
  - `complete_returns_version_size_and_composite_hash` — full happy path;
    assert body fields equal independently recomputed values
    (`Manifest::to_wire_string` + `sha256(manifest)` — reuse
    `tests/content_hash_modes_test.rs`'s reference-root helper).
  - `complete_with_stale_if_match_is_rejected` — bind a first version, start
    a second multipart, pass the pre-bind ETag → precondition-failed; session
    still `in_progress`.
  - `complete_with_missing_parts_lists_them` — plan 3 parts, report 2,
    complete → error carrying exactly the missing part number; assert **no**
    backend `complete_multipart` call happened (request-counting backend
    wrapper, same pattern as `content_hash_modes_test.rs`'s counting backend).
  - `complete_wildcard_if_match_succeeds`.
- `tests/error_mapping_test.rs` — new `MultipartPartsMissing` row (409).
- OpenAPI diff review: `git diff docs/api/api.json` shows only the complete
  operation change.

### Dependencies & ordering

**After 1.10** (same function). **Before 3.4** (introspect reuses the
missing-parts computation — extract it as a small
`fn missing_part_numbers(session, parts) -> Vec<u32>` helper so 3.4 can call
it). Serial with both.

### Sonnet-agent breakdown

- **Agent 3.3-A** — "Multipart complete: 200 rich body (version/size/root/manifest per ADR-0006) + If-Match precondition + 409-with-missing-parts; DTO/routes/OpenAPI/docs + tests" (one commit).

### Decision gates / blockers

None hard (the 204→200 break is inside an unreleased P2 surface on this
branch; flag it in the PR description, not a gate).

---

## 8. Item 3.4 — `GET /files/{id}/multipart/{upload_id}` (introspect / resume)

### Current state vs gap

No such route exists: the multipart section of `src/api/rest/routes.rs`
registers only POST initiate, POST complete, DELETE abort (`routes.rs:437-506`).
The schema was deliberately shaped for this endpoint —
`multipart_uploads.declared_size` + `part_size` exist so introspect can
"reconstitute the full parts plan without persisting every per-part planned
row" (`src/infra/storage/entity/multipart_upload.rs:29-35` doc comment), and
`docs/features/multipart-coordinator.md`'s acceptance criteria still carry the
unresolved introspect checkbox. This is a ship-or-defer decision the team
never made.

### Concrete approach (if SHIP)

1. `src/domain/multipart_service.rs` — new
   `pub async fn introspect_multipart_upload(&self, ctx, file_id, upload_id)
   -> Result<MultipartUploadStatus, DomainError>`:
   - `require_file` + authorize with `actions::WRITE` (introspect exists to
     *resume an upload*; keeping it on the write action matches
     initiate/complete/abort and avoids a read-capable-but-not-write principal
     steering an upload). Note the choice in the doc comment.
   - Load session; mask foreign/missing `upload_id` as
     `multipart_upload_not_found` with the same `session.file_id != file_id`
     guard as complete (`multipart_service.rs:593-595`).
   - `list_multipart_parts`; compute `missing_part_numbers(...)` (helper
     extracted in 3.3).
   - **Resume URLs**: if `state == InProgress && expires_at > now`, re-mint a
     signed part URL for each *missing* part (same per-part token minting the
     initiate path uses — reuse its plan/token helper; token `exp` capped at
     the session's remaining `expires_at`, not a fresh full TTL). Terminal or
     expired sessions return state only, no URLs.
2. Domain type `MultipartUploadStatus` in `src/domain/multipart.rs`:
   `{ upload_id, version_id, state, declared_mime, declared_size, part_size,
   created_at, expires_at, received: Vec<ReceivedPart{part_number, size,
   uploaded_at}>, missing: Vec<MissingPart{part_number, offset, size,
   upload_url: Option<String>}> }`.
3. `src/api/rest/dto.rs` — `MultipartStatusDto` (+ sub-DTOs) mirroring the
   above; `src/api/rest/handlers.rs` — `introspect_multipart` handler
   (Path `(file_id, upload_id)`, no body); `src/api/rest/routes.rs` — GET
   registration mirroring abort's builder (`.error_401/403/404/500`,
   `operation_id("file_storage.introspect_multipart")`), 200 with schema.
4. Docs: `multipart-coordinator.md` — tick the acceptance-criteria line with
   this PR reference and document the flow; `docs/api.md` row; `make openapi`.

### Concrete approach (if DEFER)

Edit `docs/features/multipart-coordinator.md`'s acceptance-criteria checkbox
and `docs/DECOMPOSITION.md`'s DoD to say "deferred to P3" with a dated note —
per the master plan's 3.4 step 3(b). (S effort, doc-only agent.)

### Tests / verification (SHIP variant)

- `tests/multipart_test.rs`:
  - `introspect_reports_received_and_missing_parts` — plan 3, upload/report 1,
    introspect → `received == [1]`, `missing == [2, 3]` with offsets/sizes
    matching the plan, fresh `upload_url` present for both.
  - `introspect_foreign_upload_id_is_not_found` — session belongs to another
    file → 404-shaped error (masking).
  - `introspect_expired_session_returns_state_without_urls`.
  - `introspect_resume_urls_expire_with_session` — decode a minted URL's token
    payload; `exp <= session.expires_at.unix_timestamp()`.
- E2E: extend 0.2's `test_multipart_full_lifecycle_against_real_sidecar`
  (item E2E) with a mid-flight introspect step + resume-URL PUT for one part.

### Dependencies & ordering

**After 3.3** (shares `multipart_service.rs`/`handlers.rs`/`dto.rs`/`routes.rs`
and reuses the missing-parts helper and, for URLs, the initiate path's token
minting). 🛑-gated.

### Sonnet-agent breakdown

- **Agent 3.4-A** (SHIP) — "GET multipart introspect/resume endpoint: status + missing-part resume URLs + tests + docs" (one commit), **or**
- **Agent 3.4-B** (DEFER) — "Mark multipart introspect deferred-to-P3 in FEATURE doc + DECOMPOSITION DoD" (one doc commit; can fold into 3.6's agent).

### Decision gates / blockers

- 🛑 **Ship or defer** — the team decision the master plan explicitly requires.
  Input needed: is resumable multipart a P2 exit criterion? If shipped, also
  confirm (a) `WRITE` (not `READ`) as the authorize action, and (b) that
  re-minting part URLs on introspect is wanted (pure-status introspect is the
  fallback scope).

---

## 9. Item 3.6 (remaining) — five per-feature FEATURE docs

### Current state vs gap

`docs/features/` contains only `multipart-coordinator.md` and
`content-hash-modes.md`. The P2 subsystems shipped on this branch — policy
engine, retention/cleanup, audit trail, ownership transfer, backend
migration — have no FEATURE docs; Tier-3's 3.6 executed only the minimal
README/DECOMPOSITION scope correction. Given the compliance weight of
`cpt-cf-file-storage-fr-audit-trail` / `-fr-ownership-transfer`, the master
plan recommends authoring them properly.

### Concrete approach

Author five docs under `docs/features/`, each mirroring
`multipart-coordinator.md`'s structure (numbered sections: Feature Context /
Actor Flows (CDSL) / Processes (CDSL) / States / Definitions of Done /
Acceptance Criteria, with `p1`/`p2` tags and `@cpt-…` requirement ids).
Ground each in the shipped code (anchors for the authoring agent):

| Doc | Requirement id | Code anchors |
|---|---|---|
| `policy-engine.md` | `fr-size-limits-policy`, allowed-types | `src/domain/policy.rs`, `policy_service.rs`, `PolicyResolver` (used at `write.rs:198-211`), routes `GET/PUT /policy`, `GET /policy/effective`; tests `tests/policy_test.rs`, `policy_authz_test.rs` |
| `retention-cleanup.md` | `fr-retention-policies`, `fr-orphan-reconciliation` | `src/domain/cleanup.rs` (SweepResult counters `:44-51`, `run_sweep` `:140-174`), retention-rule routes, `store/lifecycle.rs`; tests `tests/cleanup_test.rs`. Must document the 2.8 invariant once that lands |
| `audit-trail.md` | `fr-audit-trail` | `src/domain/audit.rs`, `audit_outbox` entity, every `AuditEntry::success` call site, the undrained-outbox caveat (Tier-4 4.1 EventBroker relay); tests `tests/audit_test.rs` |
| `ownership-transfer.md` | `fr-ownership-transfer` | `write.rs::transfer_ownership` (+ usage delta `write.rs:386-397` area), handler `handlers.rs:498-515`, the 2.12 PARTIAL caveat (nil-UUID-only target validation; principal-existence validation blocked on account-management SDK) — document as an explicit limitation, not silently |
| `backend-migration.md` | `fr-backend-migration` | `src/domain/service/backend.rs::migrate_backend` (CAS per 2.3, mode-aware hash verify per ADR-0006), handler `handlers.rs:482-491`, route `POST /files/{id}/migrate` |

Also: add the five entries to `docs/DECOMPOSITION.md`'s decomposition table /
TOC, and cross-link from `README.md`'s implementation-status section.

Acceptance-criteria sections must reflect *actual* state — checked for what
ships, unchecked-with-note for known partials (2.12's target validation,
4.1's outbox relay). No aspirational checkmarks.

### Tests / verification

Docs-only: `ls docs/features/` shows 7 files; each new doc has an
acceptance-criteria section; `rg -n "fr-audit-trail" docs/features/audit-trail.md`
non-empty; DECOMPOSITION TOC updated; no code diffs
(`git diff --stat -- '*.rs'` empty for these commits).

### Dependencies & ordering

Schedule **last** (Wave 4) so the docs describe post-1.10/2.8/3.3/(3.4) reality.
Fully parallel-safe with E2E.

### Sonnet-agent breakdown

- **Agent 3.6-A** — "FEATURE docs: policy-engine + retention-cleanup" (one commit).
- **Agent 3.6-B** — "FEATURE docs: audit-trail + ownership-transfer + backend-migration; DECOMPOSITION/README cross-links" (one commit).
  (Two agents keep each within a reviewable size; they touch disjoint files —
  parallel-safe except both append to DECOMPOSITION.md's TOC: have 3.6-B own
  the DECOMPOSITION/README edits for both batches.)

### Decision gates / blockers

None (the "author full docs vs one-line correction" choice was already
recommended by the master plan; treat authoring as decided unless the team
objects).

---

## 10. Item E2E — harness completion + the Tier 0–3 E2E backlog

> **DECISION (2026-07-08): DEFERRED — backlog specified, cases not authored.**
> The E2E harness **cannot run in the current environment**: `pytest` is not
> installed (`ModuleNotFoundError: No module named 'pytest'`), and the suite
> further needs `httpx` + `cryptography` and a built
> `cf-gears-example-server --features file-storage` binary plus an
> orchestrated server+sidecar pair. Authoring ~10 pytest cases that cannot be
> executed/verified here would violate the "don't write tests you can't run"
> constraint and risks shipping broken assertions. **The underlying behaviors
> are already covered by the Rust unit/integration suite** (358+ tests, run
> green after every item this round). The full Slice A case list + Slice B
> (USER_B fixture) below stands as the authoritative backlog; author and run
> them once a machine with the pytest deps + release binaries is available
> (the runbook in this section is ready to use verbatim). See
> [[project_file_storage_tests_sqlite_only]].

### Current state vs gap

The harness **exists and is CI-wired**: `testing/e2e/suites/file_storage/`
has a seams suite (shared CI server), a `lifecycle/` suite that spawns its own
server+sidecar pair (`lifecycle/conftest.py` — requires `FS_E2E_BINARY`, falls
back to `target/debug/sidecar` for `FS_SIDECAR_BINARY`, fixed Ed25519 seed,
private port 8096), and a `lifecycle_s3/` suite; `tools/scripts/ci.py:484-493`
sets `FS_E2E_BINARY`/`FS_SIDECAR_BINARY` automatically. What's missing is the
**backlog of cases the remediation steps promised** (unit coverage landed;
each plan step names its E2E case) and a **second-principal fixture**
(`E2E_AUTH_TOKEN_USER_B`) for the cross-user authorization cases — currently
only single-token (`E2E_AUTH_TOKEN`, default `e2e-token-tenant-a`,
`file_storage/conftest.py:40`) is supported; the master plan (line ~252-254)
points at `E2E_AUTH_TOKEN_TENANT_B` in
`testing/e2e/suites/resource_group/test_integration_seams.py:207` as the
pattern to copy.

### Concrete approach

**Slice A — env-only cases (no new auth infra), all in
`testing/e2e/suites/file_storage/lifecycle/test_file_storage_lifecycle.py`
unless noted:**

| Case | Source item | Notes |
|---|---|---|
| `test_finalize_forged_size_hash_is_rejected` | 0.1 | presign, PUT real bytes (or skip PUT), POST finalize with forged `size`/`hash_hex` + the real token → 400/409; `GET /files/{id}/versions` still `pending` |
| `test_multipart_full_lifecycle_against_real_sidecar` | 0.2 | initiate → PUT ≥2 parts to sidecar → complete → download; **assert the 3.3 response shape** (`version_id`/`size`/`content_hash`/`hash_mode`/`part_count`/`manifest`) and client-side manifest re-verification per `content-hash-modes.md` |
| `test_upload_over_2mib_succeeds_once_policy_allows` | 1.2(a) | >2 MiB payload; PUT returns 200 not 413; bytes round-trip |
| `test_upload_exceeding_policy_max_size_rejected_mid_stream` | 1.2(b) | policy ceiling; assert 4xx, not a completed upload |
| `test_sidecar_healthz_reachable` (+ `/readyz` after 1.6) | 1.6 | plain `httpx.get` against the lifecycle sidecar |
| `test_upload_content_mime_mismatch_is_rejected` | 1.10 | declare `image/png`, upload JPEG bytes → finalize rejected |
| range + `Content-Type`/`ETag` assertions folded into `test_localfs_single_part_full_lifecycle`'s download step | 1.11 | `Range: bytes=…` → 206 + correct `Content-Range`; after 1.11 lands: `Content-Type` == declared MIME, `ETag` present |
| `test_policy_set_is_idempotent_under_repeated_calls` | 2.4 | seams suite (`test_file_storage_seams.py`) — shared server is fine |
| introspect/resume step inside the multipart lifecycle | 3.4 | only if 3.4 ships |

(2.5's `test_unknown_file_returns_problem_json` already exists in the seams
suite — no action.)

**Slice B — cross-user cases (needs the USER_B fixture):**

1. Fixture work in `testing/e2e/suites/file_storage/conftest.py`: add
   `auth_headers_user_b` reading `E2E_AUTH_TOKEN_USER_B` (default mirroring
   the resource_group pattern — same tenant, different subject), `pytest.skip`
   when the server's test authenticator rejects it.
2. `test_cross_user_policy_write_denied_by_real_authz` (0.7) — user B attempts
   `PUT /policy` scoped to user A's resources → 403.
3. `test_cross_user_file_listing_denied_by_real_authz` (0.9) — user B lists
   with an owner filter targeting user A → no cross-user rows / 403.

**Runbook** (put at the top of the new test file(s) and in
`docs/operations.md`'s testing section):

```bash
cargo build -p cf-gears-example-server --features file-storage   # or the ci.py release path
cargo build -p cf-gears-file-storage --bin sidecar
FS_E2E_BINARY=target/debug/cf-gears-example-server \
FS_SIDECAR_BINARY=target/debug/sidecar \
pytest testing/e2e/suites/file_storage -q
```

(Exact server package/feature name: confirm against what
`tools/scripts/ci.py:484-493` builds — the agent must read that block and
reuse its binary path logic, not invent one.)

### Tests / verification

The deliverable *is* tests. Acceptance: full suite green locally via the
runbook; each case fails when its guarded behavior is reverted (spot-check at
least the forged-finalize case by temporarily reverting the read-back — in a
scratch worktree, not committed); CI e2e job (`ci.py`) picks the new cases up
with zero config (Slice A) / with the one new env var (Slice B).

### Dependencies & ordering

Slice A: after Waves 1–2 (it asserts 1.6/1.10/1.11/3.3 behavior); the 0.1/0.2
cases have no new-code dependency and could start earlier, but keeping one
agent-run after Wave 2 avoids re-touching the same test file. Slice B: after
the 🛑 fixture question resolves. Both parallel-safe with 3.6.

### Sonnet-agent breakdown

- **Agent E2E-A** — "Lifecycle/seams E2E backlog: forged-finalize, multipart lifecycle (+manifest re-verify), body-size, healthz/readyz, MIME-mismatch, range/ETag, policy idempotency" (one commit, Python only).
- **Agent E2E-B** — "Second-principal fixture (E2E_AUTH_TOKEN_USER_B) + cross-user policy/listing authz cases" (one commit).

### Decision gates / blockers

- 🛑 **E2E-B only**: confirm the example server's test authenticator accepts a
  second static token for a distinct user in the same tenant (check how
  `e2e-token-tenant-a` is validated server-side and how resource_group's
  `E2E_AUTH_TOKEN_TENANT_B` is provisioned in CI). If it doesn't, that's a
  small server-side test-auth change that must be scoped/approved first.
- Slice A has **no gate**: `FS_E2E_BINARY`/`FS_SIDECAR_BINARY` are already
  produced and exported by `tools/scripts/ci.py`; locally the runbook above
  suffices.

---

## Appendix — file-conflict matrix (for scheduling agents)

| File | Items touching it |
|---|---|
| `src/bin/sidecar.rs`, `src/bin/sidecar_tests.rs` | 1.6, 1.11, 0.1 (callback headers) |
| `src/domain/multipart_service.rs` | 1.10, 3.3, 3.4, (1.11: one-line `sign_url` call-site) |
| `src/api/rest/handlers.rs` | 3.3, 3.4, 0.1 |
| `src/api/rest/routes.rs`, `src/api/rest/dto.rs` | 3.3, 3.4 |
| `src/domain/service/write.rs` | 1.10 (helper extraction only) |
| `src/infra/signed_url/mod.rs` | 1.11 |
| `src/infra/storage/repo/version_repo.rs`, `src/domain/cleanup.rs` | 2.8 |
| `src/infra/storage/migrations/` | 2.7 (new file; 2.8 only if the optional index rides along) |
| `docs/features/`, `DECOMPOSITION.md`, `README.md` | 3.6 (+3.3/3.4 doc edits — 3.6 runs last) |
| `testing/e2e/suites/file_storage/` | E2E only |

Serialization rules: **1.6 → 1.11**; **1.10 → 3.3 → 3.4**; **0.1 after 3.4**
(or rebase over it); everything else parallel. `tests/error_mapping_test.rs`
gains rows in 3.3 (and only there) — no conflict.
