feat(migration): plan entry v2 issuer (backfills, base-read capability, commit identity)
Plan entry v2 issuer (backfill epochs) for the RelayHistory to 96Bulls migration. Stacked on #111 (feat/migration-grant-pair-binding @ 2adf414a). Draft; do not merge.
Spec of record: 96BullSessions main @ f67f741, which is #86 merged. That commit holds docs/contracts/relayhistory-export-plan-entry-v2-issuer.md (blob b4bde2e6) and .vectors.json (blob 59a8bde9). Contract review PASSED at d4e0e040, and its blobs are identical. The vectors are vendored byte for byte at packages/server/src/__tests__/fixtures/relayhistory-export-plan-entry-v2-issuer.vectors.json.
Behaviour
-
Legacy conditional (§1). An absent
backfillsorbackfills: []takes the v1 path byte for byte: the same stored plan and digest, v1's rule of 1 to 1000 manifests, and unchanged audit rows. A non-empty list stores av: 2plan whose digest covers{v: 2, grant_id, exported_at, manifests, backfills}. In that casemanifestsmay be[], but the plan needs at least one entry in total (ruling 2). Abackfillsthat isnullor not an array isinvalid_plan. -
v2 entries (§2). The input has exactly the spec's keys. RelayAuth derives
grant_id,backfill_sha256andraw_bytes, and refuses a client value for any of them.recordsis mandatory, offsets run on from 0, andstart_offset + bytes≤ MAX is checked exactly. A key may appear only once across both lists. A v2 entry in the wrong org gets a genericinvalid_plan, and the precisebackfill_org_mismatchappears only in the internal auditdetail. v1 keepsplan_org_mismatch(ruling 3). -
Two token classes (§3).
commit {key, base}base-read {key, kind: "backfill_base_read"}sub agent_relayhistory_migrationagent_relayhistory_migration_base_readtokenClass migration_assertionmigration_base_readscopes ["rth:sync"]["rth:migration_base_read"]aud [<origin>][<origin>/v1/pipeline/manifests/base]TTL ≤ 60 s ≤ 30 s identity meta.upload(§5)meta.read(§4);meta.uploadabsentjti tok_mbr_Both forms look the key up only in
backfills. The pair, live-identity and revocation checks are v1's, and tenancy comes only from the stored entry. Sending bothkindandbase, or any otheruploadkey, gets 400invalid_upload_selector. Base-read issuance is default-off:RELAYAUTH_ENABLE_MIGRATION_BASE_READ(chartmigrationAssertions.baseReadEnabled) must be exactlytrue, or the mint gets 403migration_base_read_disabled(§9). -
basebounds (§5). Exactly 7 keys.revisionis 1 to MAX−1,chunk_countis 0 to MAX−4096, andepochis 0 to 4294967294.version,ownerandwriterare 1 to 512 characters, andownermay not start withmigration:.content_sha256is lowercase hex. -
Audit (rulings 1 and 4). v1 mint and refusal rows are exactly #111's. v2 rows carry
upload_kind,uploadKeyHash,entry_sha256and, for a commit,base_revision. They omit the raw nonce and are correlated bytokenId, which is the jti. A v2 refusal row carriesupload_kindand the key hash. No row holds a raw key, token or base content. -
SDK verifier and types.
meta.uploadmust be the v1 or v2 commit shape, andmeta.readmust be the base-read shape. A token carrying both is refused.
Ruling 5 (#112)
#112's request fingerprint must strip grant_id from backfill entries. That change is made on #112's own branch as a stacked follow-up; see #112.
Tests (new or changed; all pass)
migration-plan-v2.test.ts: 67, including subtests.- Reproduces
legacy.absentandlegacy.empty(stored JSON and digesta6682d4d…). - Reproduces
v2_plan(f29e6cc4…),backfill_entry_stored,backfill_entry_sha256,base_read_meta_read(including canonical JSON) andcommit_meta_upload. - Covers the §2 and §10 negatives, including null or string
backfills, no entries at all, the wrong-org audit reason, and MAX and MAX+1 bounds. - Covers 9 stored-plan tampers, 17
basebound cases, and a backfill-only plan.
- Reproduces
migration-assertion.test.ts, v2 section: 6 tests, 22 including subtests.- Grants: v1 stored byte for byte, with no v2 audit fields.
- Mint claims and audit for both classes, plus the v1 audit key set pinned exactly.
- A 12-case selector, list, pair and tenant refusal matrix, with every refusal audited.
- The verifier audience matrix in both directions, and RelayAuth refusing the capability as a refresh token and as a bearer token.
- The base-read flag (default-off, enabled only by exactly
true, never overriding the main switch). - A backfill-only grant, and the wrong-org response kept generic with the reason only in the audit.
- SDK
verify.test.ts: 1 test that accepts the commit shape inmeta.uploadand the base-read shape inmeta.read, and refuses 19 confused or malformed shapes.
Validation
Local, all uncached:
| Check | Result |
|---|---|
turbo typecheck --force | 11/11 |
turbo build --force | 9/9 |
turbo test --force (16/16, 0 cached) | server 816/816, sdk 171/171, ai 70/70, core 19/19, migrate 18/18, relayauth 12/12, types 1/1 |
| Root tests | 8/8 |
Server test:e2e | 40 pass, 0 fail, 93 skipped (live environment) |
helm template | renders RELAYAUTH_ENABLE_MIGRATION_BASE_READ only when baseReadEnabled=true |
Prettier (npx --yes prettier@3.6.2 --no-config --check <the 16 added or modified files>) exits 1. The files it warns about are exactly the ones that already fail the same check at 2adf414a. deployment.yaml is a Go template, so it can't be parsed as YAML. The new and newly touched files, the fixture, values.yaml and lib/migration-grants.ts, pass. I did not reformat existing files.
Hosted CI: ci.yml triggers only for PRs targeting main and has no workflow_dispatch. CI therefore runs on the temporary CI-only draft #118, from this exact head. #118 will be closed once CI reports.
Nothing was merged, deployed or touched live, and no keys or credentials were created or used.
Independent SOC2 re-review pending.
🤖 Generated with Claude Code
Agent Relay sessions
claudesession719b5435· opened viagh pr create· last active 2026-10-31