Verification: P0.8.2 η Three-Zone Retention
Task: P0.8.2
Branch: feature/p0-8-2-retention
Commit: 617a67cd (Step 4 — feat(p0-8-2): η three-zone retention (hot/warm/cold))
Date: 2026-04-17
1. Test evidence
1.1 Retention-only run (hermetic)
$ npm test -- --testPathPattern='domains/proof/retention'
Test Suites: 1 passed, 1 total
Tests: 51 passed, 51 total
Snapshots: 0 total
Time: 18.6 s
All 51 tests in src/__tests__/domains/proof/retention.test.ts pass.
1.2 Full-suite run
$ npm test
Test Suites: 1 failed, 17 passed, 18 total
Tests: 1 failed, 830 passed, 831 total
Time: 47.8 s
The single failing test is startup — subprocess smoke › tsx src/server.ts boots and logs [Startup] Phase 1. It passes cleanly when run in isolation:
$ npm test -- --testPathPattern='startup'
Test Suites: 1 passed, 1 total
Tests: 40 passed, 40 total
Time: 21.7 s
This is the documented pre-existing subprocess-smoke flakiness noted in the memory log for Wave E (project_r75_phase0_wave_e_2026_04_17.md → “Pre-existing intermittent startup-subprocess smoke test flakiness in CI”). Root cause: subprocess spawn + Phase 1 stdout timing under concurrent Jest worker pressure; not caused by this task.
Retention-only tests: 51 / 0 failures. Full-suite minus the flake: 830 / 0 real failures. No retention-related regressions. thought_records migration-rebuild preserves P0.7.2 trail tests (all trail/repository tests still pass at 48/48).
1.3 Coverage (retention module)
retention.ts | 91.25% stmts | 77.14% branch | 100% funcs | 91.25% lines
Uncovered lines are defensive error-path throws: RetentionIntegrityError branches for hot/warm rows with NULL content, the unreachable “cold-going-backwards” guard. These are correctness guards, not reachable through the public API.
2. Lint evidence
$ npm run lint
(no output — clean)
Zero ESLint errors. Zero warnings.
3. Build evidence
$ npm run build
(no output — clean)
TypeScript tsc produces no diagnostics.
4. Acceptance criteria checklist
| # | AC (task-breakdown §P0.8.2) | Evidence |
|---|---|---|
| 1 | Hot zone: last 100 records — full content in DB | computeZone tests (positions 1..100 → hot); archiveRecord — hot zone (no-op) suite confirms content preserved. |
| 2 | Warm zone: records 101–1000 — content compressed (JSON → gzip → base64) | archiveRecord — hot → warm suite: asserts zone='warm', content=NULL, content_compressed != NULL, and gunzipContent(content_compressed) === original. |
| 3 | Cold zone: records 1001+ — content hash only (full content deleted) | archiveRecord — warm → cold + hot → cold direct suites: zone='cold', content=NULL, content_compressed=NULL, content_hash=sha256(original). |
| 4 | archiveRecord(id) moves record to next zone based on age/position |
All archive suites plus archiveRecord — errors cover happy-path + unknown-id. |
| 5 | retrieveRecord(id) decompresses if Warm, returns hash stub if Cold |
retrieveRecord suite: hot returns content, warm decompresses, cold returns hash_only with content_available: false, unknown id returns null. |
| 6 | Test: hot → warm → cold transitions; verify content availability per zone | end-to-end hot → warm → cold sequence (AC6) single test walks the full lifecycle and checks both zone transitions and content_available flip. |
All 6 acceptance criteria satisfied with direct test coverage.
5. Invariant checklist
| Invariant (contract §5) | Evidence |
|---|---|
| I1 — forward-only (no backwards producible) | computeZone is a pure function of position; archiveRecord threads the computed target into a forward-only UPDATE. The backwards-from-cold branch in retention.ts throws RetentionIntegrityError and is unreachable through the public API. |
| I2 — idempotency | archiveRecord — idempotency (I2) suite: warm + cold second-call returns changed:false with row bytes unchanged. |
| I3 — chain-integrity preservation | archiveRecord — chain integrity (I3, I4) suite: snapshot-and-compare on id, type, task_id, agent_id, timestamp, prev_hash, hash, created_at. |
| I4 — row preservation (no DELETE) | row preservation (I4): COUNT(*) unchanged after any archival. |
| I5 — position stability within transaction | Implicit in SQLite semantics (rowid monotonicity) — no separate test; getRecordPosition tests prove correct positioning. |
| I6 — gzip round-trip | gzipContent / gunzipContent round-trip (I6) suite: empty, ASCII, unicode, 72-KiB, base64 format regex. |
| I7 — content_hash stability | warm → cold preserves all chain fields + content_hash stability (I7) compares warm-transition content_hash with cold-transition content_hash — byte-identical. |
6. 5-step artefacts shipped
| Step | File | Commit |
|---|---|---|
| 1. Audit | docs/audits/p0-8-2-retention-audit.md |
38742b9a |
| 2. Contract | docs/contracts/p0-8-2-retention-contract.md |
5e3c0ac3 |
| 3. Packet | docs/packets/p0-8-2-retention-packet.md |
4ab5cd9f |
| 4. Implement | src/db/migrations/005_retention.sql, src/domains/proof/retention.ts, src/__tests__/domains/proof/retention.test.ts |
617a67cd |
| 5. Verify | docs/verification/p0-8-2-retention-verification.md |
(this commit) |
7. Schema changes summary
New migration: src/db/migrations/005_retention.sql.
- Rebuilds
thought_recordstable via canonical SQLite “create new, copy rows, drop old, rename” pattern to drop theNOT NULLoncontent. - Adds 3 nullable columns:
zone,content_compressed,content_hash. - Re-creates the two indexes from 003:
idx_trail_task,idx_trail_prev. - No new index on
zonein this migration (deferred; audit §4 notes a future task may addidx_trail_zonefor bulk passes).
No source file modifications beyond the three new files. thought_records.sql (003) is not edited — the 005 migration handles the column change in an additive way. src/db/index.ts migration runner already auto-discovers 005 from the filename prefix.
8. Interpretation choices (for PR reviewer)
- Position vs TTL: used position (1..100 Hot / 101..1000 Warm / 1001+ Cold) per task spec, overriding the HERITAGE TTL-based interpretation in the eta-proof-store-extraction.md. Documented in audit §1.3.
- Per-task positioning: a record’s position is evaluated within its own
task_idchain (ordered byrowid DESC). This matches P0.7.2’s per-task chain-linkage semantics and means a 50-record chain is entirely Hot even if a sibling chain has 5000 records. - NULL zone = Hot: records written by P0.7.2
createThoughtRecordhave no zone column written at insert time (P0.7.2 didn’t know about retention).retrieveRecordtreatszone IS NULLas'hot'so existing records behave correctly without a backfill. content_hashcolumn distinct fromhash:hashcommits to the 6-field subset (already includes content);content_hashis a convenience column for the Cold zone’s “hash-only” read path, sincehashalone doesn’t expose a content-level digest isolated from the other record fields.- No MCP tool: task spec does not register one.
retention.tsis a library-level surface; a later P0.8.x task may wrap it in a tool. contentnow nullable: required by AC3. SQLite can’t mutate NOT NULL in place; we rebuilt the table in 005.
9. Deviations from packet
None. Implementation follows the packet §3 skeleton, the Zod + error schemas in contract §2, and the migration in packet §2 (with an expansion from 3 ALTER statements to a table-rebuild + 3 columns + 2 indexes). The rebuild was required because SQLite can’t drop a NOT NULL constraint via ALTER; this was flagged in audit §5 and spelled out in the final 005 migration.
10. Blockers / residual risks
- Startup subprocess smoke flake — pre-existing, documented, not caused by this task.
- No index on
zone— fine for single-record archival; a bulk retention pass would benefit. Deferred to future task. - No scheduled retention pass —
archiveRecordis called explicitly by callers. A cron-style iterator is out of P0.8.2 scope. - Retention is position-based only — time-based (TTL) retention is the donor approach and can be layered on later as a separate dimension if needed.
- No unarchive path — forward-only by design.
Ready for PR.