Skip to content

Define retention schemas and synthetic verification - #93

Closed
tnunamak wants to merge 5 commits into
mainfrom
feat/retention-manifest-schemas
Closed

tnunamak wants to merge 5 commits into
mainfrom
feat/retention-manifest-schemas

Conversation

@tnunamak

@tnunamak tnunamak commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

The Claude Code and Codex connectors store session files lossily. Large fields keep only a bounded prefix plus the true byte length: 500 characters for attachments, a bounded prefix for tool results, and GUARD_PREFIX_BYTES of 64 KB for file reads (connectors/codex/index.ts:135). The bound exists because one tool result can reach hundreds of megabytes, and reading it whole would make collector memory scale with session size. The bytes themselves are never stored, so the durable record cannot reconstruct the session.

This is the first change toward a byte archive that keeps full content without loading it into memory. It adds the contract only: schemas, dormant stream declarations, a compatibility check, and a verifier over supplied synthetic records and bytes. The PR that wires checkRetentionCompatibility in will be the one that enables capture.

No collector code path reaches the new modules. checkRetentionCompatibility and the verifier are called only from their own tests, and RETENTION_STREAMS is declared but never read by a collector, so nothing captures a file, uploads a blob or writes a cursor. retention-runtime.test.ts reads manifests/claude_code.json and manifests/codex.json and asserts neither declares a retention stream, which are the two connectors this contract is written for.

Verify, from packages/polyfill-connectors:

node --test --import tsx --test-concurrency=2 \
  src/retention-schemas.test.ts src/retention-runtime.test.ts src/retention-verifier.test.ts

36 pass, 0 fail. Note that scripts/run-tests.mjs ignores argv and globs a fixed set, so npm test -- <filter> runs the whole suite instead.

Artifact classes are declared per connector

retention-schemas.ts previously hardcoded an enum of fourteen artifact classes, eight of them connector-specific, so adding a connector meant editing a shared schema. b076a2d4 had already settled this question the other way for default stream sets, making each collector-definition.ts the single place its own streams are written. This branch predated that commit and is now reconciled with it.

RETENTION_BASE_ARTIFACT_CLASSES keeps only what means the same thing for every connector: images, documents, unknown, unclassified. Each connector declares its own classes in its own collector-definition.ts, and collector-registry.ts assembles them into CONNECTOR_RETENTION_ARTIFACT_CLASSES beside the existing definitionStreams helper.

Two classes were connector-specific despite unprefixed names. shell_snapshots (connectors/codex/index.ts:234) and the tool-results/*.txt sidecars (connectors/claude_code/index.ts:475) become codex.shell_snapshots and claude.tool_result_sidecars. A test pins the prefix in both directions: an unprefixed class fails, and so does one whose prefix names no registered connector.

The enum stays closed, which is load-bearing. A free-form class string would let an omission entry cite a rule no owner policy could have contained, defeating omission_policy_mismatch. Closing over declared classes keeps that check meaningful while letting each connector own its entries, and the inferred type is still the exact literal union, now assembled rather than written out.

Checked by adding a throwaway connector declaring two classes: both became citable in policy rules and omission entries with retention-schemas.ts unmodified, undeclared classes stayed rejected, and the prefix test caught the connector while it was declared but not yet registered.

RETENTION_STREAMS was flagged in this PR as the same kind of hardcoded table. It is not — its six names are the format's own streams, identical for every connector, so there is no per-connector copy to drift. Left as data with a comment.

Tests that would otherwise pass on broken code

The corrupt-bytes tests all ran the complete_raw path, where the whole-source source_digest_mismatch backstop catches corruption on its own — so disabling the chunk-level blob comparison entirely left the suite green. The added test verifies a complete_with_exclusions capture with omitted_bytes > 0 and no sourceBytes, which is the durable-readback case where that backstop is off, and corrupts a retained blob both by flipping a byte and by changing its length. Replacing the byte-length and SHA-256 comparison in retention-verifier.ts:94 with if (false) now fails with same-length corrupt blob bytes must not be accepted.

required root and chunk keys cannot be dropped or padded with unknown keys now drops every required key rather than a chosen subset. It previously listed 13 of the root schema's 23 required keys and 5 of the chunk schema's 15, omitting the shared provenance block on both sides, so marking classifier_version, connector_instance_id or source_root_id optional left the suite green.

It now derives each key set with Object.keys on the fixture instead of listing names. v1 root, page, chunk and receipt fixtures round-trip without defaults (retention-schemas.test.ts:89) parses each fixture and asserts it equals itself, so no field is defaulted or dropped and a fixture's key set is its schema's required set. The asserted counts, 23 root and 15 chunk, fail if a fixture stops covering its schema. Marking any of those three provenance keys optional in retention-schemas.ts now fails with chunk must require <key>.

Both sabotages were re-run after the taxonomy change and still fail.

Not verified: making blob_ref.sha256 and size_bytes optional without also loosening strictObject is behaviorally inert and no test detects it. The chunk-level .refine already requires those fields to equal their sibling chunk values, and those siblings are required, so validation still fails through the refine. Detecting that relaxation on its own would mean changing the refine, not the field modifier.

Also not caught: disabling only the first of the blob comparison's four clauses, because the other three still reject the corrupt blob. That gap predates this change and is not introduced by it.

Assisted-by: AI

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Schema Health Check — All Clear

All 73 scopes have consistent local schema files.

Three mutations of the retention code survived the existing suite, so the
tests did not pin the behavior they claimed.

Every corrupt-bytes test ran the complete_raw path, where the whole-source
source_digest_mismatch backstop catches corruption on its own. That backstop
is disabled once omitted_bytes > 0, so the chunk-level blob comparison was
never the thing that failed a test. Two new cases build a
complete_with_exclusions capture and corrupt a retained blob: one on the
durable-readback path with no sourceBytes, one against frozen bytes. Both
assert accepted === false and a blob_integrity_mismatch issue.

Nothing asserted that a required key is required or that an unknown key is
rejected, so both .optional() and a strictObject to loose relaxation passed
unnoticed. The schema tests now drop each required root, chunk and blob_ref
key and assert rejection, and reject a smuggled unknown key in the root,
chunk, blob_ref, encoded path, manifest page reference, policy and
observation. Matching verifier cases prove the same at the behavior level:
a record with a smuggled key, or a root missing session identity, does not
verify.

Verified by re-applying each mutation and confirming the suite fails:
blob comparison to if (false) fails both exclusion-path cases; blob_ref
optional plus loose fails the strictness cases; optional session identity
fails the required-key cases. Production code is unchanged.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
The drop test listed 13 of the root schema's 23 required keys and 5 of the
chunk schema's 15. The omitted 10 on each side are exactly the shared
provenance block, including classifier_version, connector_instance_id and
source_root_id. Making any of those optional left the suite green.

Derive the key set from each fixture instead of listing it. The round-trip test
already proves both fixtures parse back to themselves with no defaults applied,
so a fixture's own key set is exactly its schema's required set, and a key added
to either schema arrives here with the fixture that has to carry it. Assert the
expected counts so a fixture that stops covering the schema fails loudly rather
than silently testing less.

Confirmed load-bearing: marking classifier_version, connector_instance_id or
source_root_id optional in retention-schemas.ts now fails with the diagnostic
naming that key. All three passed before this change. Retention suites 32/32
and package verify pass.

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
Assisted-by: AI
@tnunamak
tnunamak force-pushed the feat/retention-manifest-schemas branch from e13adf9 to 8bf0782 Compare September 13, 2026 02:04
…my-0913

Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
`retention-schemas.ts` hardcoded an enum of fourteen artifact classes, eight
of them connector-specific (`claude.tool_result`, `codex.reasoning`, and six
more). Adding a connector meant editing a shared schema file, which is the
arrangement `b076a2d4` removed for default stream sets by making each
`collector-definition.ts` the single place its own streams are written.

The taxonomy now splits the same way. `RETENTION_BASE_ARTIFACT_CLASSES` keeps
only what means the same thing for every connector (`images`, `documents`,
`unknown`, `unclassified`). Each connector declares its own classes in its own
`collector-definition.ts`, and the collector registry assembles them into
`CONNECTOR_RETENTION_ARTIFACT_CLASSES`, next to the existing
`definitionStreams` helper and for the same reason.

Two classes were connector-specific despite unprefixed names and moved to
their owners: `shell_snapshots` is a Codex stream
(`connectors/codex/index.ts:234`), and the `tool-results/*.txt` sidecars are a
Claude Code on-disk structure (`connectors/claude_code/index.ts:475`). They
are now `codex.shell_snapshots` and `claude.tool_result_sidecars`, so the
connector prefix is uniform and a new test pins it: an unprefixed or
unregistered declaration fails.

The enum stays closed, which is load-bearing. A free-form class string would
let an omission entry cite a rule no owner policy could have contained,
defeating the verifier's `omission_policy_mismatch` check. Closing the enum
over declared classes keeps that check meaningful while letting each connector
own its entries. The inferred TypeScript type is still the exact literal
union, now assembled rather than written out.

`RETENTION_STREAMS` in `retention-runtime.ts` was flagged as the same kind of
hardcoded table but is not one, and is left as data with a comment saying why:
its six names are the retention wire format's own streams, each bound to the
schema that validates it, identical for every connector. Adding a connector
adds artifact classes, never a retention stream, so there is no per-connector
copy there to drift.

Adding a connector now touches that connector's own directory and the registry
list that registers it, and no shared schema. Verified by adding a throwaway
connector declaring two classes: both became citable in policy rules and
omission entries with `retention-schemas.ts` unmodified, undeclared classes
stayed rejected, and the prefix test caught the connector while it was
declared but not yet registered.

36 pass, 0 fail, up from 32; the four added tests cover the base staying
connector-agnostic, the prefix invariant, admissibility with closure, and each
connector contributing its own entries. Both existing guarantees still fail
under their sabotages: replacing the blob byte-length and SHA-256 comparison
in `retention-verifier.ts` with `if (false)` fails with `same-length corrupt
blob bytes must not be accepted`, and marking `classifier_version`,
`connector_instance_id` or `source_root_id` optional each fails with `chunk
must require <key>`.

Noted, not introduced: disabling only the first of that comparison's four
clauses is not caught, because the other three still reject the corrupt blob.
That gap predates this change.

Assisted-by: AI
Signed-off-by: Tim Nunamaker <tnunamak@gmail.com>
@tnunamak

Copy link
Copy Markdown
Contributor Author

Superseded. Byte retention landed as a content-addressed spool with a four-field record reference (data-connect #106, data-connectors #100), which needs none of this contract. The chunk/coverage/omission model here is only required for owner-directed omission, which is not scheduled; the design reasoning is retained for when sparse archives are specified.

Assisted-by: AI

@tnunamak tnunamak closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant