feat(migration): plan entry v2 issuer (backfills, base-read capability, commit identity)

Draft
AG
13d ago
+2155-98

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 backfills or backfills: [] 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 a v: 2 plan whose digest covers {v: 2, grant_id, exported_at, manifests, backfills}. In that case manifests may be [], but the plan needs at least one entry in total (ruling 2). A backfills that is null or not an array is invalid_plan.

  • v2 entries (§2). The input has exactly the spec's keys. RelayAuth derives grant_id, backfill_sha256 and raw_bytes, and refuses a client value for any of them. records is mandatory, offsets run on from 0, and start_offset + bytes ≤ MAX is checked exactly. A key may appear only once across both lists. A v2 entry in the wrong org gets a generic invalid_plan, and the precise backfill_org_mismatch appears only in the internal audit detail. v1 keeps plan_org_mismatch (ruling 3).

  • Two token classes (§3).

    commit {key, base}base-read {key, kind: "backfill_base_read"}
    subagent_relayhistory_migrationagent_relayhistory_migration_base_read
    tokenClassmigration_assertionmigration_base_read
    scopes["rth:sync"]["rth:migration_base_read"]
    aud[<origin>][<origin>/v1/pipeline/manifests/base]
    TTL≤ 60 s≤ 30 s
    identitymeta.upload (§5)meta.read (§4); meta.upload absent
    jtitok_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 both kind and base, or any other upload key, gets 400 invalid_upload_selector. Base-read issuance is default-off: RELAYAUTH_ENABLE_MIGRATION_BASE_READ (chart migrationAssertions.baseReadEnabled) must be exactly true, or the mint gets 403 migration_base_read_disabled (§9).

  • base bounds (§5). Exactly 7 keys. revision is 1 to MAX−1, chunk_count is 0 to MAX−4096, and epoch is 0 to 4294967294. version, owner and writer are 1 to 512 characters, and owner may not start with migration:. content_sha256 is lowercase hex.

  • Audit (rulings 1 and 4). v1 mint and refusal rows are exactly #111's. v2 rows carry upload_kind, uploadKeyHash, entry_sha256 and, for a commit, base_revision. They omit the raw nonce and are correlated by tokenId, which is the jti. A v2 refusal row carries upload_kind and the key hash. No row holds a raw key, token or base content.

  • SDK verifier and types. meta.upload must be the v1 or v2 commit shape, and meta.read must 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.absent and legacy.empty (stored JSON and digest a6682d4d…).
    • Reproduces v2_plan (f29e6cc4…), backfill_entry_stored, backfill_entry_sha256, base_read_meta_read (including canonical JSON) and commit_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 base bound cases, and a backfill-only plan.
  • 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 in meta.upload and the base-read shape in meta.read, and refuses 19 confused or malformed shapes.

Validation

Local, all uncached:

CheckResult
turbo typecheck --force11/11
turbo build --force9/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 tests8/8
Server test:e2e40 pass, 0 fail, 93 skipped (live environment)
helm templaterenders 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

  • claude session 719b5435 · opened via gh pr create · last active 2026-10-31