# C13 64 GB Capacity Cap + Oldest-Segment-Dropped Policy

**Task**: AZ-293_c13_capacity_cap_policy
**Name**: C13 Capacity Cap Policy
**Description**: Enforce the per-flight ≤ 64 GB cap from AC-NEW-3 by observing the segment files written by the writer thread (AZ-291), deleting the oldest CLOSED segment when the cumulative on-disk size of the flight directory crosses the configured cap (default 64 GB; configurable down for tests), and emitting a `kind="segment_rollover"` `FdrRecord` carrying the dropped segment number, byte count freed, and total bytes after the drop. The drop is ALWAYS recorded — there is no config flag that silences `segment_rollover` records (per AC-NEW-3 + ADR-008 + C13-ST-01). The currently-open segment is NEVER dropped; only sealed segments older than the current one are eligible.
**Complexity**: 5 points
**Dependencies**: AZ-291_c13_writer_thread, AZ-292_c13_flight_header_footer, AZ-272_fdr_record_schema, AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module
**Component**: c13_fdr (epic AZ-248 / E-C13)
**Tracker**: AZ-293
**Epic**: AZ-248 (E-C13)

### Document Dependencies

- `_docs/02_document/contracts/shared_fdr_client/fdr_record_schema.md` — defines the canonical shape of `kind="segment_rollover"` payloads (consumed: `old_segment`, `new_segment`, `total_bytes_after`).
- `_docs/02_document/contracts/shared_config/composition_root_protocol.md` — Config block carrying `flight_cap_bytes` (default 64 GB; lowered in tests).

## Problem

The writer thread from AZ-291 rotates per-segment when the per-segment size cap is reached, but does NOT enforce the per-flight 64 GB cap from AC-NEW-3. Without:

- Drop policy when the flight directory crosses 64 GB, the writer would either run out of disk (likely on the Jetson NVM where other binaries live) or fail with `ENOSPC` and degrade per AC-5 of AZ-291. AC-NEW-3 requires the cap to be ENFORCED, not detected.
- Oldest-segment-dropped semantics, the cap could be enforced by truncating the current segment — which would corrupt records mid-write and break the wire-format invariant from AZ-272.
- A `kind="segment_rollover"` record per drop, the drop is silent — directly violating AC-NEW-3 ("no silent drops") and the C13-ST-01 security test ("no config flag silences these record kinds"). The drop record is ALSO the post-flight tooling's only way to learn that the flight USED to have records the file directory no longer contains.

## Outcome

- After every per-segment rotation the writer performs (AZ-291), this task checks whether the flight directory's cumulative on-disk byte size exceeds `flight_cap_bytes`. If yes, it deletes the oldest CLOSED segment (segment 0 first, then segment 1, etc., never the currently-open segment) and repeats until the directory size is back under cap.
- For each drop, a `kind="segment_rollover"` `FdrRecord` is enqueued via the shared `FdrClient` for `producer_id="shared.fdr_client"`. The record carries `payload.old_segment` (the segment number that was deleted), `payload.new_segment` (the writer's currently-open segment number), and `payload.total_bytes_after` (the post-drop on-disk byte count).
- The cap is configurable via `composition_root_protocol`'s `flight_cap_bytes` field (default 64 GB; tests use 4 KiB or similar to exercise the policy without filling real disks).
- The cap policy NEVER drops the currently-open segment (would interrupt mid-record); NEVER drops `segment-0000.fdr` if it contains the `flight_header` UNLESS the directory is so over-cap that no other segment exists to drop (in that case the operator's flight has exceeded what the cap can absorb and a hard ERROR + GCS alert path is triggered, distinct from the normal drop path).
- The post-flight reader uses the sequence of `segment_rollover` records to reconstruct what was dropped vs. what was retained, and the `FlightFooter`'s `rollover_count` (from AZ-292) reports the total number of cap-driven drops.

## Scope

### Included

- A `CapacityCapPolicy(writer: FileFdrWriter, cap_bytes: int, fdr_client: FdrClient)` class wired into `FileFdrWriter` via a documented post-rotation hook.
- The hook is invoked AFTER every successful per-segment rotation (AZ-291's rotation completion path); it walks `flight_root/<flight_id>/`, sums on-disk byte sizes of all `segment-NNNN.fdr` files (excluding the currently-open segment whose byte count comes from the writer's running `bytes_written` counter), and decides whether to drop.
- Drop ordering: oldest segment first. Segment numbers are monotonic (per AZ-291's filesystem layout `segment-NNNN.fdr`), so "oldest" = lowest segment number among CLOSED segments.
- Drop mechanics: `os.unlink` the segment file, increment the writer's `rollover_count` (the counter from AZ-292), enqueue the `kind="segment_rollover"` record via the shared `FdrClient`. The record's `payload.old_segment` is the deleted segment number; `payload.new_segment` is the writer's currently-open segment; `payload.total_bytes_after` is recomputed after the unlink.
- Loop until under cap: if a single drop does not bring the directory under cap (e.g. very large segments + long flight), drop the next-oldest segment and emit another `segment_rollover` record. AC-3 covers loop termination.
- Special-case "only segment 0 with header remains, AND it is over cap by itself": this is the operator-error case (cap configured smaller than a single segment + header). Hard-fail: log ERROR `kind="fdr.cap_misconfigured"`, invoke the GCS alert (the same one AZ-291 wires for ENOSPC), and refuse to drop `segment-0000.fdr`. The flight continues in degraded mode — segments accumulate on disk past the cap until either a normal drop becomes possible or the operator lands.
- A diagnostic INFO log per drop (`kind="fdr.cap_drop"; old_segment; new_segment; total_bytes_after`) — distinct from the FDR record itself; the log line is for operator debugging, the FDR record is the canonical audit trail.
- Configuration: `flight_cap_bytes` is a single integer field on the `FdrWriterConfig` consumed via `composition_root_protocol`; the default is `64 * 1024**3` (64 GiB exactly per AC-NEW-3); valid range is `1024 .. 2**40` (1 KiB minimum for tests, 1 TiB maximum sanity bound).
- The cap policy does NOT have a config flag to disable it. The implementation MUST NOT expose a "disable cap" boolean on any Config block — verified by C13-ST-01 (that test scans the config schema for any flag that could disable rollover-drop emission).

### Excluded

- Per-segment file rotation itself — owned by AZ-291.
- `FlightHeader` / `FlightFooter` accounting and `rollover_count` storage — owned by AZ-292 (this task increments the counter; the counter itself lives in the writer).
- The `kind="segment_rollover"` payload schema — owned by AZ-272 (this task constructs records that conform to that schema).
- Mid-flight tile snapshot path and failed-tile thumbnail rate cap — tasks #4 and #5.
- ENOSPC degraded-mode handling — owned by AZ-291 (this task uses the same GCS alert callable for the cap-misconfigured edge case).
- Post-flight reader logic that reconstructs dropped data from the rollover records — out of scope this cycle.
- Cross-flight retention (deleting OLD flight directories to free disk) — out of scope; the cap is per-flight, the operator manages cross-flight cleanup.

## Acceptance Criteria

**AC-1: Drop oldest closed segment when directory exceeds cap**
Given a flight directory with segments 0..3 each sized 100 KiB, currently-open segment 4 at 50 KiB, and `flight_cap_bytes = 350 KiB`
When the writer rotates to segment 5 (segment 4 is now closed at 100 KiB; total = 500 KiB > 350 KiB cap)
Then segment 0 is unlinked from disk; the writer's `rollover_count` increments by 1; a `kind="segment_rollover"` record lands on the FDR with `payload.old_segment=0`, `payload.new_segment=5`, `payload.total_bytes_after == sum(file_sizes(segment-0001..segment-0005))`

**AC-2: Loop until under cap**
Given a flight directory with segments 0..9 each 100 KiB and `flight_cap_bytes = 350 KiB`, currently-open segment 10
When the post-rotation hook runs
Then segments 0, 1, 2, 3, 4, 5, 6 are deleted (in order); 7 `kind="segment_rollover"` records land on the FDR (one per drop); the directory total falls to ≤ 350 KiB

**AC-3: Loop terminates even when bytes_after never reaches cap (degenerate case)**
Given a contrived test where `cap_bytes` is 100 KiB but the currently-open segment alone is already 200 KiB, AND only segment 0 (containing the flight_header) closed before
When the post-rotation hook runs
Then segment 0 is NOT dropped (it contains the header); ONE ERROR log (`kind="fdr.cap_misconfigured"`) is emitted; ONE GCS alert is invoked; the loop terminates within bounded time (≤ 100 ms p99); the flight continues in degraded mode

**AC-4: Currently-open segment is NEVER dropped**
Given a flight directory with segments 0..2 closed and segment 3 currently open
When the post-rotation hook runs (after rotating to segment 4) AND the cap is exceeded by the currently-open segment alone
Then segment 4 (the new currently-open segment) is NOT dropped; older segments (0, 1, 2, 3) are dropped first per the oldest-first rule

**AC-5: segment_rollover record contains canonical fields**
Given any cap-driven drop event
When the test parses the resulting `segment_rollover` record
Then `payload` has exactly `old_segment` (int), `new_segment` (int), `total_bytes_after` (int >= 0); the OUTER envelope's `producer_id == "shared.fdr_client"` (per the schema contract); the record's `ts` is within 100 ms of the `os.unlink` call

**AC-6: No config flag disables segment_rollover emission**
Given the project's full Config schema and every documented config preset
When the test scans config classes for a field that could suppress `kind="segment_rollover"` records (per C13-ST-01)
Then no such field exists; injecting a synthetic preset that attempts to suppress the record fails type-check or runtime validation

**AC-7: Default cap is exactly 64 GiB**
Given a default `FdrWriterConfig` constructed with no overrides
When the test reads `cap_bytes`
Then `cap_bytes == 64 * 1024**3` (exactly 64 GiB)

**AC-8: rollover_count from FlightFooter matches segment_rollover record count**
Given a flight that triggered N cap-driven drops over its lifetime
When `close_flight()` runs and the test parses the footer
Then `footer.rollover_count == N + per_segment_rotations` (the AZ-292 counter increments on EVERY rotation; cap-driven drops add to it; the segment_rollover record count provides cross-validation against the cap-driven subset)

## Non-Functional Requirements

**Performance**
- Post-rotation hook execution time p99 ≤ 50 ms per rotation under steady-state (one drop per rotation at most, typical case). Per AC-2 worst case, multiple drops may extend the hook; the implementation MUST NOT block the writer thread's drain loop for more than 100 ms total even under worst-case multi-drop bursts (cap configured very low for tests).
- `os.unlink` on the per-flight NVM (typical Jetson Orin Nano Super filesystem) takes < 5 ms p99 for files up to 256 MiB; the implementation relies on this, no async unlink.
- Directory scan for byte counting uses a per-flight sorted-segment-list cached by the policy class (refreshed on each rotation), NOT a fresh `os.scandir` per check — `os.scandir` cost grows with segment count and would dominate for long flights.

**Reliability**
- The cap policy MUST NOT delete a segment whose deletion is in progress (idempotency: a re-entry into the hook before `os.unlink` returns is impossible because the writer thread is the sole invoker, but the policy MUST handle the case where a previous unlink left a stale entry in the cached segment list — refresh the list from disk on every entry).
- A failed `os.unlink` (e.g. read-only filesystem, ENOENT for an already-deleted segment due to operator manual intervention) is logged at WARN with `kind="fdr.cap_unlink_failed"` and the policy continues to the next-oldest segment; it does NOT halt the writer.
- The `segment_rollover` record is enqueued via the shared `FdrClient` (which has its own overrun policy from AZ-274); if the FdrClient's buffer is full at the moment of drop, the record itself may overrun — that is fine, AZ-274's overrun policy emits a `kind="overrun"` record with `producer_id="shared.fdr_client"` and the drop is still observable through AZ-292's `records_dropped_overrun` counter.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | 4 closed segments × 100 KiB + 50 KiB open + cap=350 KiB; trigger rotation to segment 5 | Segment 0 deleted; one segment_rollover record with correct payload fields |
| AC-2 | 10 closed segments × 100 KiB + cap=350 KiB; one rotation | 7 oldest segments deleted (in order); 7 segment_rollover records; final dir total ≤ 350 KiB |
| AC-3 | Cap=100 KiB, segment 3 currently open at 200 KiB, only segment 0 (header) closed | Segment 0 NOT deleted; one ERROR log "fdr.cap_misconfigured"; one GCS alert; hook terminates ≤ 100 ms |
| AC-4 | Currently-open segment exceeds cap by itself; older segments exist | Older segments drop first; currently-open never dropped |
| AC-5 | Trigger any drop; parse the resulting segment_rollover record | payload has exactly old_segment / new_segment / total_bytes_after; outer producer_id == "shared.fdr_client"; ts within 100 ms of unlink |
| AC-6 | Scan Config class hierarchy for "disable_segment_rollover" / "suppress_*" / "no_rollover" fields | None found; synthetic config trying to disable fails validation |
| AC-7 | Default `FdrWriterConfig()` | `cap_bytes == 64 * 1024**3` |
| AC-8 | Run a flight with N cap-driven drops + M per-segment rotations; parse footer + segment_rollover records | `footer.rollover_count == N + M`; segment_rollover record count == N |
| NFR-perf-hook | Microbench post-rotation hook with 1 drop | p99 ≤ 50 ms |
| NFR-perf-multi-drop | Microbench worst-case multi-drop burst | total ≤ 100 ms |
| NFR-reliability-stale-list | Manually delete a segment file under the policy; trigger hook | WARN log "fdr.cap_unlink_failed"; policy continues |

## Constraints

- The cap is enforced ONLY via oldest-segment-dropped. The implementation MUST NOT truncate any segment file, MUST NOT modify any record once written, MUST NOT seek into closed segments. AZ-291's "append-only between rotations" invariant extends to "no in-place modification across the entire flight".
- The cap is applied to the SUM of all on-disk segment file sizes (closed + currently-open). Sidecar files outside the segment files (e.g. mid-flight tile snapshots from task #4 — those land in a separate path under `flight_root/<flight_id>/tiles/`) are NOT counted toward the cap; their cap is owned by task #4. This task's cap is segment-file-only.
- The cap policy hook is wired by the composition root, NOT by AZ-291's writer constructor (so AZ-291 stays focused on per-segment lifecycle without knowing about per-flight cap policy). The composition root injects the policy as a callback the writer invokes after each rotation.
- The configuration field name is `flight_cap_bytes`; renaming is a breaking change requiring a major bump on `composition_root_protocol`.
- The `kind="segment_rollover"` record is mandatory per AC-NEW-3 + ADR-008 + C13-ST-01. There is no future PBI that adds an opt-out flag — that is a contract test, not a code-review preference.

## Risks & Mitigation

**Risk 1: Filesystem reports cached size, drop appears not to free space**
- *Risk*: Some filesystems lazily release `unlink`ed inodes; `os.statvfs` immediately after `unlink` shows the bytes still allocated; the cap policy thinks it needs to drop more.
- *Mitigation*: The policy uses `os.path.getsize` summed across actual segment files, NOT `statvfs` of the mount. Once the segment file is `unlink`ed, it no longer appears in `os.scandir` and is not summed. This is correct independent of inode-release timing.

**Risk 2: Operator manually deletes a segment mid-flight**
- *Risk*: An operator with shell access to the companion deletes `segment-0001.fdr`; the policy's cached segment list is now stale.
- *Mitigation*: AC-NFR-reliability-stale-list — the policy refreshes from `os.scandir` on every hook entry, logs WARN if a previously-tracked segment is missing, and continues. Treat operator interference as out-of-band noise, not a failure mode.

**Risk 3: Cap policy and AZ-291's per-segment rotation race**
- *Risk*: The policy reads the segment list while AZ-291 is opening a new segment; the new segment file may exist but be empty.
- *Mitigation*: The hook is invoked SYNCHRONOUSLY by AZ-291's rotation completion path (not by a separate thread or timer). The writer thread is the sole mutator; there is no concurrent rotation. AC-1 verifies this end-to-end.

**Risk 4: GCS alert flooded by cap-misconfigured edge case**
- *Risk*: AC-3 path triggers GCS alerts on every rotation; alerts overwhelm the GCS link.
- *Mitigation*: Per-flight rate cap on `kind="fdr.cap_misconfigured"` GCS alerts — at most one per flight, since the misconfig is a flight-level constant. After the first alert, subsequent occurrences are logged at ERROR but NOT alerted.

## Runtime Completeness

- **Named capability**: per-flight 64 GB cap enforcement with oldest-segment-dropped + canonical drop-record emission (architecture / E-C13 / AC-NEW-3, ADR-008).
- **Production code that must exist**: real `os.unlink` on segment files, real `FdrRecord(kind="segment_rollover")` enqueue via the shared FdrClient, real config-driven cap reading, real loop-until-under-cap with degenerate-case handling.
- **Allowed external stubs**: tests MAY stub the `FdrClient` (use FakeFdrSink from AZ-275) and the GCS alert callable; production wiring uses the real instances via the composition root.
- **Unacceptable substitutes**: cap detection without enforcement ("we just log a warning when we exceed cap"), per-record drop instead of per-segment drop ("simpler to drop the oldest record"), in-place segment truncation ("avoid the unlink overhead"), suppressing the segment_rollover record under any config preset ("debug builds don't need the audit trail"), or replacing the cap policy with cross-flight cleanup ("we'll delete old FLIGHTS to make room"). All of those break AC-NEW-3 + ADR-008.
