feat: auditable corpus manifest with tombstones and lifecycle guards (#25) #85

Merged
rockachopa merged 2 commits from timmy/25-corpus-provenance-tombstones into main 2026-10-24 12:02:52 +00:00
Member

Closes #25

What changed

The ingest script is now an auditable corpus pipeline backed by scripts/corpus_manifest.py.

  • Provenance (criterion 1). Each sample records consent (version + recordedAt), source.kind and the raw-input digest, both hashes (rawInput, derived), the derivation block (format, maxDimension, metadataStripped, pipeline: timmy-ingest/2), review status and timestamp, the salted-subjectKey split, and lifecycle: {state: active}. The caller's input path is never recorded — only its digest. consentVersion stays in place for older readers, so the shipped node ingest test needed only the new required --contribution-id.
  • Revocation (criterion 2). --revoke <contribution-id> --reason <text> deletes the derived image and any raw pre-derivation copy (matched by raw digest, never a user path), then appends a tombstone naming every removed artifact by contribution id + digest. The tombstone keeps identifiers and hashes and deliberately drops labels, subjectKey, and relativePath. The manifest stays append-only: the original sample record survives the withdrawal that documents it, and active_records() filters anything tombstoned so a trainer reading through it cannot train on withdrawn data. Deletion is path-guarded (_safe_dataset_path) against escaping the dataset root.
  • Illegal transitions (criterion 3). Refused, not silently applied: revoked → active, unknown state, revocation without a reason, revoking an unknown contribution, revoking twice, a duplicate contributionId, and a second label set for the same derived image. The duplicate guard runs before any byte is written, so a refused ingest leaves no orphan image in the active dataset.

TDD evidence (watched RED, then GREEN)

tests/corpus-manifest.test.py, one vertical slice at a time:

  1. provenance → RED (unrecognized arguments: --contribution-id, record had no v2 fields) → GREEN.
  2. tombstone + byte removal + append-only history + active_records → RED (--revoke unsupported; active_records missing) → GREEN.
  3. illegal transitions → RED on the two real gaps (a second revoke appended a second tombstone; a duplicate contribution appended a second sample) → GREEN.
  4. orphan-image gap found while reviewing the diff → RED (two files left in images/) → guard moved before the write → GREEN.

Honest disclosure: transition()'s backwards-move guards passed on first write. I report that as a hold-out check, not a RED cycle.

Local gates

  • python3 tests/corpus-manifest.test.py -v — 8/8 pass.
  • npm test — 217 tests, 216 pass; the one failure is staging-health.test.js → "malformed and escaping base paths are rejected at startup" ("/../git" was accepted), a timing-sensitive 800 ms spawn budget. Pre-existing: reproduced 1/5 runs on a clean git archive origin/main checkout with none of these changes, and 9 consecutive npm test runs passed with them. Not caused by, and not fixed by, this PR.
  • python3 tests/staging-deploy.test.py — 20/20; npm run check:syntax and npm run check:diff clean.
  • git status clean before commit; no dataset bytes, weights, or secrets added; __pycache__ stays ignored.

Demonstration path

No browser surface changed, so scripts/record_release_demo.mjs is untouched and makes no new claim. The nearest honest user-visible effect is the withdrawn-receipt tombstone already on camera for #24 (one receipt queued locally, then a tombstone naming every derived artifact by contribution ID) — this PR is the corpus-side counterpart of that same identifier, and the CLI accepts the UUIDs the app mints (verified with a uuid4() smoke run: ingest → active, revoke → one artifact removed, only manifest.jsonl left). The release delta should keep saying "no new user-visible feature is claimed" for this change.

Not merged: RC approval stays with the release job.

Closes #25 ## What changed The ingest script is now an auditable corpus pipeline backed by `scripts/corpus_manifest.py`. - **Provenance (criterion 1).** Each sample records consent (`version` + `recordedAt`), `source.kind` and the raw-input digest, both hashes (`rawInput`, `derived`), the derivation block (`format`, `maxDimension`, `metadataStripped`, `pipeline: timmy-ingest/2`), review status and timestamp, the salted-`subjectKey` split, and `lifecycle: {state: active}`. The caller's input path is never recorded — only its digest. `consentVersion` stays in place for older readers, so the shipped node ingest test needed only the new required `--contribution-id`. - **Revocation (criterion 2).** `--revoke <contribution-id> --reason <text>` deletes the derived image and any raw pre-derivation copy (matched by raw digest, never a user path), then appends a tombstone naming every removed artifact by contribution id + digest. The tombstone keeps identifiers and hashes and deliberately drops `labels`, `subjectKey`, and `relativePath`. The manifest stays append-only: the original sample record survives the withdrawal that documents it, and `active_records()` filters anything tombstoned so a trainer reading through it cannot train on withdrawn data. Deletion is path-guarded (`_safe_dataset_path`) against escaping the dataset root. - **Illegal transitions (criterion 3).** Refused, not silently applied: `revoked → active`, unknown state, revocation without a reason, revoking an unknown contribution, revoking twice, a duplicate `contributionId`, and a second label set for the same derived image. The duplicate guard runs **before** any byte is written, so a refused ingest leaves no orphan image in the active dataset. ## TDD evidence (watched RED, then GREEN) `tests/corpus-manifest.test.py`, one vertical slice at a time: 1. provenance → RED (`unrecognized arguments: --contribution-id`, record had no v2 fields) → GREEN. 2. tombstone + byte removal + append-only history + `active_records` → RED (`--revoke` unsupported; `active_records` missing) → GREEN. 3. illegal transitions → RED on the two real gaps (a second revoke appended a second tombstone; a duplicate contribution appended a second sample) → GREEN. 4. orphan-image gap found while reviewing the diff → RED (two files left in `images/`) → guard moved before the write → GREEN. Honest disclosure: `transition()`'s backwards-move guards passed on first write. I report that as a **hold-out check**, not a RED cycle. ## Local gates - `python3 tests/corpus-manifest.test.py -v` — 8/8 pass. - `npm test` — 217 tests, 216 pass; the one failure is `staging-health.test.js` → "malformed and escaping base paths are rejected at startup" (`"/../git" was accepted`), a timing-sensitive 800 ms spawn budget. **Pre-existing**: reproduced 1/5 runs on a clean `git archive origin/main` checkout with none of these changes, and 9 consecutive `npm test` runs passed with them. Not caused by, and not fixed by, this PR. - `python3 tests/staging-deploy.test.py` — 20/20; `npm run check:syntax` and `npm run check:diff` clean. - `git status` clean before commit; no dataset bytes, weights, or secrets added; `__pycache__` stays ignored. ## Demonstration path No browser surface changed, so `scripts/record_release_demo.mjs` is untouched and makes no new claim. The nearest honest user-visible effect is the withdrawn-receipt tombstone already on camera for #24 (one receipt queued locally, then a tombstone naming every derived artifact by contribution ID) — this PR is the corpus-side counterpart of that same identifier, and the CLI accepts the UUIDs the app mints (verified with a `uuid4()` smoke run: ingest → active, revoke → one artifact removed, only `manifest.jsonl` left). The release delta should keep saying "no new user-visible feature is claimed" for this change. Not merged: RC approval stays with the release job.
rockachopa added 1 commit 2026-10-24 10:58:48 +00:00
Promote the ingest script into an auditable corpus pipeline. Each sample now
records consent (version + timestamp), source kind and raw-input digest, both
hashes, the derivation pipeline, review status, split, and lifecycle state, with
the caller's path never written down.

Withdrawal is a new `--revoke <contribution-id> --reason` operation: it deletes
the derived image and any raw pre-derivation copy, then appends a tombstone that
addresses each removed artifact by contribution id and digest while dropping
labels, subject key, and paths. History is append-only, so the original sample
record survives the withdrawal it documents.

Illegal moves are refused instead of silently applied: revoked -> active,
revoking an unknown or already-revoked contribution, an unknown state, a
duplicate contribution id, and a second label set for the same derived image.
The duplicate guard runs before any byte is written, so a refused ingest cannot
strand an image in the active dataset.

Tests written first and watched fail (tests/corpus-manifest.test.py): provenance,
tombstone + byte removal, active-record filtering, and the transition guards.
The transition helper itself passed on first write and is reported as a
hold-out check rather than a RED cycle. Wired into the quality workflow and
check:syntax; contract documented in docs/CORPUS-MANIFEST.md.

No browser surface changed, so record_release_demo.mjs is untouched and claims
nothing new: the nearest honest visible effect is the withdrawn-receipt
tombstone already recorded for #24.
rockachopa added 1 commit 2026-10-24 11:12:51 +00:00
style: end the new corpus files with a single newline
Quality gates / quality (pull_request) Successful in 13m1s
578cc9f129
CI's diff-hygiene gate rejects a trailing blank line at EOF; the local
check:diff run happened before the first commit existed, so it checked the
previous range and missed it.
rockachopa left a comment
Author
Member

Release-engineering review of 578cc9f (7 files, +651/-38).

Verified in a clean worktree at the PR head:

  • python3 tests/corpus-manifest.test.py -v -> 8/8 OK (provenance, tombstone + byte removal, append-only history, active_records filtering, and every refused transition including the duplicate-ingest orphan-image guard).
  • npm test -> 217/217 pass on this host.
  • Diff scope is the feature only: corpus_manifest.py, the ingester, its tests/docs, one workflow line, package.json. No secrets, no model weights, no medical images, no binaries (0 binary patches).
  • Read scripts/corpus_manifest.py end to end: records are append-only; the tombstone drops labels/subjectKey/relativePath and keeps only contribution id + digests; deletion goes through _safe_dataset_path so it cannot escape the dataset root; the raw pre-derivation copy is matched by raw digest, never by a caller path.
    Non-blocking note (not a blocker, recorded for the next pass): revoke() unlinks bytes before appending the tombstone, so a crash in that window would delete data without leaving a record. Deleting first is the right privacy priority; a recovery note would make the window auditable.

Approving for merge.

Release-engineering review of 578cc9f (7 files, +651/-38). Verified in a clean worktree at the PR head: - `python3 tests/corpus-manifest.test.py -v` -> 8/8 OK (provenance, tombstone + byte removal, append-only history, active_records filtering, and every refused transition including the duplicate-ingest orphan-image guard). - `npm test` -> 217/217 pass on this host. - Diff scope is the feature only: corpus_manifest.py, the ingester, its tests/docs, one workflow line, package.json. No secrets, no model weights, no medical images, no binaries (0 binary patches). - Read scripts/corpus_manifest.py end to end: records are append-only; the tombstone drops labels/subjectKey/relativePath and keeps only contribution id + digests; deletion goes through _safe_dataset_path so it cannot escape the dataset root; the raw pre-derivation copy is matched by raw digest, never by a caller path. Non-blocking note (not a blocker, recorded for the next pass): revoke() unlinks bytes before appending the tombstone, so a crash in that window would delete data without leaving a record. Deleting first is the right privacy priority; a recovery note would make the window auditable. Approving for merge.
Author
Member

Release-engineering review of 578cc9f (7 files, +651/-38).

Verified in a clean worktree at the PR head:

  • python3 tests/corpus-manifest.test.py -v -> 8/8 OK (provenance, tombstone + byte removal, append-only history, active_records filtering, and every refused transition including the duplicate-ingest orphan-image guard).
  • npm test -> 217/217 pass on this host.
  • Diff scope is the feature only: corpus_manifest.py, the ingester, its tests/docs, one workflow line, package.json. No secrets, no model weights, no medical images, no binaries (0 binary patches).
  • Read scripts/corpus_manifest.py end to end: records are append-only; the tombstone drops labels/subjectKey/relativePath and keeps only contribution id + digests; deletion goes through _safe_dataset_path so it cannot escape the dataset root; the raw pre-derivation copy is matched by raw digest, never by a caller path.
    Non-blocking note (not a blocker, recorded for the next pass): revoke() unlinks bytes before appending the tombstone, so a crash in that window would delete data without leaving a record. Deleting first is the right privacy priority; a recovery note would make the window auditable.

Verdict: mergeable. Formal GitHub-style self-approval is refused by this Gitea build (approve-your-own-pull is not allowed), so this review is recorded here and the merge is the release engineer action.

Release-engineering review of 578cc9f (7 files, +651/-38). Verified in a clean worktree at the PR head: - `python3 tests/corpus-manifest.test.py -v` -> 8/8 OK (provenance, tombstone + byte removal, append-only history, active_records filtering, and every refused transition including the duplicate-ingest orphan-image guard). - `npm test` -> 217/217 pass on this host. - Diff scope is the feature only: corpus_manifest.py, the ingester, its tests/docs, one workflow line, package.json. No secrets, no model weights, no medical images, no binaries (0 binary patches). - Read scripts/corpus_manifest.py end to end: records are append-only; the tombstone drops labels/subjectKey/relativePath and keeps only contribution id + digests; deletion goes through _safe_dataset_path so it cannot escape the dataset root; the raw pre-derivation copy is matched by raw digest, never by a caller path. Non-blocking note (not a blocker, recorded for the next pass): revoke() unlinks bytes before appending the tombstone, so a crash in that window would delete data without leaving a record. Deleting first is the right privacy priority; a recovery note would make the window auditable. Verdict: mergeable. Formal GitHub-style self-approval is refused by this Gitea build (approve-your-own-pull is not allowed), so this review is recorded here and the merge is the release engineer action.
rockachopa merged commit 6aacefca65 into main 2026-10-24 12:02:52 +00:00
rockachopa deleted branch timmy/25-corpus-provenance-tombstones 2026-10-24 12:02:54 +00:00
Sign in to join this conversation.