diff --git a/.opencode/plans/delegated-release-service/evidence/w0.6-aggregator-history.md b/.opencode/plans/delegated-release-service/evidence/w0.6-aggregator-history.md new file mode 100644 index 0000000000..01b0857625 --- /dev/null +++ b/.opencode/plans/delegated-release-service/evidence/w0.6-aggregator-history.md @@ -0,0 +1,95 @@ +# W0.6 Aggregator Historical Input Evidence + +Outcome: **proved for normal-size commit frames**. + +## Source selection + +Selected source: the relay `com.atproto.sync.subscribeRepos` firehose, decoded by the installed `@atcute/firehose` client against `ComAtprotoSyncSubscribeRepos.mainSchema`. + +Jetstream alone is **not sufficient**. Its commit event preserves record JSON, record CID, repo revision, and `time_us`, but it does not provide the signed repo commit or CAR/MST blocks that bind that JSON to the publisher repository at that event. A later `com.atproto.sync.getRecord` call is also insufficient: after `strict -> relaxed -> strict`, it returns only the final strict record and proof. Jetstream JSON can be retained as a diagnostic comparison, not as authoritative historical state. + +The selected `subscribeRepos#commit` frame provides: + +- `seq`: relay-wide stream sequence. +- `ops[]`: ordered repository operations, each with path and record CID. +- `repo` and `rev`: publisher DID and repository revision. +- `commit`: signed repository commit CID. +- `blocks`: a CAR diff containing the commit, changed MST path, and changed record blocks. + +The prototype copies the CAR into every queued operation before acknowledging the source frame. Under the spike's single-relay, single-cursor assumption, `(seq, operationIndex)` is a deterministic total order of arrival from that relay. It is not repository commit-chain order. `operationIndex` distinguishes multiple operations in one commit frame. + +## Executable proof + +`apps/aggregator/src/history-spike/firehose-history.ts` is a standalone source/proof adapter. It is not wired into production ingestion. + +For each create/update operation it: + +1. Rejects `tooBig` frames because their event-specific block set is incomplete. +2. Copies the frame CAR into the queued event together with DID, rev, commit CID, record CID, path, `seq`, and operation index. +3. Requires the CAR's sole root to equal the frame commit CID. +4. Recomputes every supplied block CID. +5. Decodes the root as an atproto repo commit and binds its DID and rev to the frame. +6. Uses `@atcute/repo` `verifyRecord` with the publisher's DID signing key to verify the commit signature and MST inclusion for the exact collection/rkey. +7. Requires the verified record CID to equal the operation CID. + +The deterministic fixture creates real P-256 signed repository commits and minimal commit/MST/record CAR proofs using the installed `@atcute/crypto`, `@atcute/mst`, `@atcute/car`, and `@atcute/repo` implementations. It emits: + +1. profile strict at `(101, 0)`; +2. profile relaxed at `(102, 0)`; +3. release at `(103, 0)`; +4. profile strict at `(104, 0)`. + +No event is processed until all four commits exist. Delivery is reversed and the relaxed event is redelivered. Verification, exact-redelivery deduplication, and sorting recover all four snapshots. The release resolves to the greatest matching profile ordering key below its own, `(102, 0)`, so its publication policy is relaxed. A separate assertion verifies that the current profile proof has the final strict CID and cannot recover the relaxed CID. + +The first and final strict records intentionally have the same content CID. They remain separate transitions because their commit CIDs, revisions, and ordering keys differ. Production deduplication therefore cannot use record CID alone. + +Negative cases reject a substituted rev, substituted operation CID, changed CAR bytes, a commit checked against an unrelated public key, and two byte-different events claiming the same `(seq, operationIndex)`. Exact redelivery uses a separately cloned, byte-equal CAR so deduplication exercises content comparison rather than object identity. + +Delete events are retained by extraction and then fail closed at verification. This spike proves create/update inclusion only; it does not construct or verify historical deletion/non-inclusion proof material. Delete transitions must not be silently skipped. + +## Limits and W10.1 handoff + +- Production must resolve the publisher's signing key at the event's DID-key epoch. Verifying old commits only against a later rotated DID document can reject valid history; blindly accepting an old key is not safe. W10.1 needs a verified DID-operation/key-history strategy. +- Relay cursor retention is finite. Production must durably advance a cursor only after event jobs and proof bytes are accepted, monitor lag, and define backfill/recovery when the cursor is outside retention. +- `tooBig` frames are intentionally rejected by this prototype. W10.1 must prove an event-specific sync/repo recovery path or fail closed; fetching the current record is not a substitute. +- Historical deletes are outside this proof. W10.1 must select and prove deletion/non-inclusion material and its queue envelope before delete transitions can affect policy history. +- `(seq, operationIndex)` is single-relay arrival order. The spike does not verify commit `prev` links or rev-chain contiguity. A profile update and release in the same atomic repository commit do not necessarily have meaningful temporal policy order merely because one op appears first. W10.1 or the RFC must define or forbid that case before policy-at-publication behavior ships. +- The spike assumes one relay and one cursor epoch. If production admits multiple relays, relay replacement, or sequence epochs, source identity must participate in deduplication and ordering/collision handling. This spike does not choose that production identity or key. +- Relay trust is limited to availability, completeness, and arrival ordering. Record authority comes from the signed commit and MST proof, not from relay or Jetstream JSON. Production still needs fork/rebase, commit-chain, and sequence-gap handling. +- This spike does not choose a D1 history schema, cooldown duration, downgrade semantics, profile-policy shape, or production Worker wiring. + +## Command evidence + +Baseline: + +```text +pnpm lint:json | jq '.diagnostics | length' +0 +``` + +Focused history test: + +```text +pnpm --filter @emdash-cms/aggregator exec vitest run test/history-spike/firehose-history.test.ts +1 file passed, 6 tests passed +``` + +Existing source-ingest tests: + +```text +pnpm --filter @emdash-cms/aggregator exec vitest run test/jetstream-ingestor.test.ts test/jetstream-client.test.ts +2 files passed, 17 tests passed +``` + +Completion checks: + +```text +pnpm build +passed (existing unresolved virtual-import and deliberate eval warnings) + +pnpm --filter @emdash-cms/aggregator typecheck +passed + +pnpm lint +0 warnings, 0 errors +``` diff --git a/apps/aggregator/src/history-spike/firehose-history.ts b/apps/aggregator/src/history-spike/firehose-history.ts new file mode 100644 index 0000000000..881ce5fa59 --- /dev/null +++ b/apps/aggregator/src/history-spike/firehose-history.ts @@ -0,0 +1,220 @@ +import * as CAR from "@atcute/car"; +import * as CBOR from "@atcute/cbor"; +import * as CID from "@atcute/cid"; +import type { CidLink } from "@atcute/cid"; +import type { PublicKey } from "@atcute/crypto"; +import { type AtprotoDid, isDid } from "@atcute/lexicons/syntax"; +import { isCommit, verifyRecord } from "@atcute/repo"; + +export const FIREHOSE_HISTORY_SOURCE = "com.atproto.sync.subscribeRepos" as const; + +export interface FirehoseRepoOp { + action: "create" | "update" | "delete"; + cid: CidLink | null; + path: string; +} + +/** The history-bearing subset of a decoded subscribeRepos commit frame. */ +export interface FirehoseCommitFrame { + repo: string; + seq: number; + rev: string; + commit: CidLink; + blocks: CBOR.Bytes; + ops: readonly FirehoseRepoOp[]; + tooBig: boolean; +} + +export interface HistoricalRecordEvent { + source: typeof FIREHOSE_HISTORY_SOURCE; + did: AtprotoDid; + sequence: number; + operationIndex: number; + orderingKey: readonly [sequence: number, operationIndex: number]; + rev: string; + commitCid: string; + collection: string; + rkey: string; + operation: FirehoseRepoOp["action"]; + recordCid: string | null; + /** Event-specific commit, MST proof, and record blocks copied before enqueue. */ + carBytes: Uint8Array; +} + +export interface VerifiedHistoricalRecordEvent extends HistoricalRecordEvent { + record: unknown; +} + +/** + * Converts one relay commit frame into queue-safe, event-specific record jobs. + * A frame can contain multiple writes, so `operationIndex` completes `seq` into + * a total ordering and idempotency key within one relay cursor epoch. + */ +export function extractHistoricalRecordEvents(frame: FirehoseCommitFrame): HistoricalRecordEvent[] { + if (!Number.isSafeInteger(frame.seq) || frame.seq < 0) { + throw new Error("firehose sequence must be a non-negative safe integer"); + } + if (!isAtprotoDid(frame.repo)) + throw new Error(`unsupported atproto repository DID: ${frame.repo}`); + const did = frame.repo; + if (frame.tooBig) { + throw new Error("tooBig firehose commits do not contain complete event-specific blocks"); + } + + const carBytes = CBOR.fromBytes(frame.blocks); + return frame.ops.map((op, operationIndex) => { + const separator = op.path.indexOf("/"); + if (separator <= 0 || separator === op.path.length - 1) { + throw new Error(`invalid repository operation path: ${op.path}`); + } + if (op.action !== "delete" && op.cid === null) { + throw new Error(`${op.action} operation is missing its record CID`); + } + if (op.action === "delete" && op.cid !== null) { + throw new Error("delete operation unexpectedly has a record CID"); + } + + return { + source: FIREHOSE_HISTORY_SOURCE, + did, + sequence: frame.seq, + operationIndex, + orderingKey: [frame.seq, operationIndex] as const, + rev: frame.rev, + commitCid: frame.commit.$link, + collection: op.path.slice(0, separator), + rkey: op.path.slice(separator + 1), + operation: op.action, + recordCid: op.cid?.$link ?? null, + // Every job owns its proof bytes; queue delay cannot turn this into a + // later PDS snapshot and callers cannot mutate sibling jobs in memory. + carBytes: carBytes.slice(), + }; + }); +} + +export async function verifyHistoricalRecordEvent( + event: HistoricalRecordEvent, + publicKey: PublicKey, +): Promise { + if (event.operation === "delete") { + throw new Error( + "historical delete/non-inclusion proof is not implemented by the W0.6 prototype", + ); + } + + const car = CAR.fromUint8Array(event.carBytes); + if (car.roots.length !== 1 || car.roots[0]?.$link !== event.commitCid) { + throw new Error("CAR root does not match the firehose commit CID"); + } + + let commitBytes: Uint8Array | undefined; + for (const block of car) { + const codec = block.cid.codec; + if (codec !== CID.CODEC_DCBOR && codec !== CID.CODEC_RAW) { + throw new Error(`unsupported CAR block codec: ${codec}`); + } + const actualCid = CID.toString(await CID.create(codec, Uint8Array.from(block.bytes))); + if (CID.toString(block.cid) !== actualCid) { + throw new Error("CAR block bytes do not match their CID"); + } + if (actualCid === event.commitCid) commitBytes = block.bytes; + } + if (commitBytes === undefined) throw new Error("CAR does not contain its root commit block"); + + const commit = CBOR.decode(commitBytes); + if (!isCommit(commit)) throw new Error("CAR root is not an atproto repo commit"); + if (commit.did !== event.did) throw new Error("commit DID does not match the firehose repo"); + if (commit.rev !== event.rev) throw new Error("commit rev does not match the firehose rev"); + + const verified = await verifyRecord({ + did: event.did, + collection: event.collection, + rkey: event.rkey, + publicKey, + carBytes: event.carBytes, + }); + if (verified.cid !== event.recordCid) { + throw new Error("verified record CID does not match the firehose operation CID"); + } + + return { ...event, record: verified.record }; +} + +/** Verifies, collision-checks, deduplicates, and orders one relay's delayed jobs. */ +export async function recoverOrderedHistory( + events: readonly HistoricalRecordEvent[], + publicKey: PublicKey, +): Promise { + const unique = new Map(); + for (const event of events) { + const key = `${event.sequence}:${event.operationIndex}`; + const previous = unique.get(key); + if (previous !== undefined) { + if (!sameEvent(previous, event)) { + throw new Error(`conflicting firehose redelivery at ${key}`); + } + continue; + } + unique.set(key, event); + } + + const verified = await Promise.all( + Array.from(unique.values(), (event) => verifyHistoricalRecordEvent(event, publicKey)), + ); + verified.sort( + (left, right) => left.sequence - right.sequence || left.operationIndex - right.operationIndex, + ); + return verified; +} + +/** Finds the profile state in force immediately before a release event. */ +export function precedingProfileEvent( + history: readonly VerifiedHistoricalRecordEvent[], + release: VerifiedHistoricalRecordEvent, + profileCollection: string, + profileRkey: string, +): VerifiedHistoricalRecordEvent | undefined { + let preceding: VerifiedHistoricalRecordEvent | undefined; + for (const event of history) { + if (compareOrder(event, release) >= 0) break; + if ( + event.did === release.did && + event.collection === profileCollection && + event.rkey === profileRkey + ) { + preceding = event; + } + } + return preceding; +} + +function compareOrder(left: HistoricalRecordEvent, right: HistoricalRecordEvent): number { + return left.sequence - right.sequence || left.operationIndex - right.operationIndex; +} + +function sameEvent(left: HistoricalRecordEvent, right: HistoricalRecordEvent): boolean { + return ( + left.source === right.source && + left.did === right.did && + left.rev === right.rev && + left.commitCid === right.commitCid && + left.collection === right.collection && + left.rkey === right.rkey && + left.operation === right.operation && + left.recordCid === right.recordCid && + equalBytes(left.carBytes, right.carBytes) + ); +} + +function equalBytes(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) return false; + } + return true; +} + +function isAtprotoDid(value: string): value is AtprotoDid { + return isDid(value) && (value.startsWith("did:plc:") || value.startsWith("did:web:")); +} diff --git a/apps/aggregator/test/history-spike/firehose-history.test.ts b/apps/aggregator/test/history-spike/firehose-history.test.ts new file mode 100644 index 0000000000..39355c9fc1 --- /dev/null +++ b/apps/aggregator/test/history-spike/firehose-history.test.ts @@ -0,0 +1,264 @@ +import * as CAR from "@atcute/car"; +import * as CBOR from "@atcute/cbor"; +import * as CID from "@atcute/cid"; +import { P256PrivateKeyExportable } from "@atcute/crypto"; +import { buildInclusionProof, MemoryBlockStore, NodeStore, NodeWrangler } from "@atcute/mst"; +import { PROFILE_NSID, RELEASE_NSID } from "@emdash-cms/atproto-test-utils/nsid"; +import { describe, expect, it } from "vitest"; + +import { + extractHistoricalRecordEvents, + precedingProfileEvent, + recoverOrderedHistory, + verifyHistoricalRecordEvent, + type FirehoseCommitFrame, + type HistoricalRecordEvent, +} from "../../src/history-spike/firehose-history.js"; + +const DID = "did:plc:history000000000000000000"; +const SLUG = "history-plugin"; + +describe("subscribeRepos historical input", () => { + it("recovers strict -> relaxed -> release -> strict after delayed, reordered delivery", async () => { + const repo = await SignedRepoFixture.create(DID); + const strictBefore = await repo.write(101, "create", PROFILE_NSID, SLUG, profile("strict")); + const relaxed = await repo.write(102, "update", PROFILE_NSID, SLUG, profile("relaxed")); + const release = await repo.write(103, "create", RELEASE_NSID, `${SLUG}:1.0.0`, { + $type: RELEASE_NSID, + package: SLUG, + version: "1.0.0", + }); + const strictAfter = await repo.write(104, "update", PROFILE_NSID, SLUG, profile("strict")); + + // Nothing is processed until every write has happened. Queue arrival is + // deliberately reversed and the relaxed event is redelivered from a + // separately deserialized queue value with byte-equal proof contents. + const relaxedRedelivery = cloneEvent(relaxed); + expect(relaxedRedelivery).not.toBe(relaxed); + expect(relaxedRedelivery.carBytes).not.toBe(relaxed.carBytes); + expect(relaxedRedelivery.carBytes).toEqual(relaxed.carBytes); + const delayedQueue = [strictAfter, release, relaxed, strictBefore, relaxedRedelivery]; + const history = await recoverOrderedHistory(delayedQueue, repo.keypair); + + expect(history.map((event) => event.orderingKey)).toEqual([ + [101, 0], + [102, 0], + [103, 0], + [104, 0], + ]); + const profiles = history.filter((event) => event.collection === PROFILE_NSID); + expect(profiles.map((event) => (event.record as { policy: string }).policy)).toEqual([ + "strict", + "relaxed", + "strict", + ]); + // Identical strict values have the same content CID, but remain distinct + // events because commit CID, rev, and source ordering are retained. + expect(profiles[0]?.recordCid).toBe(profiles[2]?.recordCid); + expect(profiles[0]?.commitCid).not.toBe(profiles[2]?.commitCid); + expect(profiles[0]?.rev).not.toBe(profiles[2]?.rev); + + const recoveredRelease = history.find((event) => event.collection === RELEASE_NSID); + expect(recoveredRelease).toBeDefined(); + const policyAtPublication = precedingProfileEvent( + history, + recoveredRelease!, + PROFILE_NSID, + SLUG, + ); + expect(policyAtPublication?.sequence).toBe(102); + if (policyAtPublication === undefined) throw new Error("preceding profile was not recovered"); + expect((policyAtPublication.record as { policy: string }).policy).toBe("relaxed"); + + for (const event of history) { + expect(event.recordCid).toMatch(/^b/); + expect(event.commitCid).toMatch(/^b/); + expect(event.rev).toMatch(/^3/); + expect(event.carBytes.byteLength).toBeGreaterThan(0); + } + }); + + it("proves a current-record fetch cannot recover the intermediate profile", async () => { + const repo = await SignedRepoFixture.create(DID); + await repo.write(201, "create", PROFILE_NSID, SLUG, profile("strict")); + const relaxed = await repo.write(202, "update", PROFILE_NSID, SLUG, profile("relaxed")); + const finalStrict = await repo.write(203, "update", PROFILE_NSID, SLUG, profile("strict")); + + const historical = await verifyHistoricalRecordEvent(relaxed, repo.keypair); + const current = await verifyHistoricalRecordEvent(finalStrict, repo.keypair); + + expect((historical.record as { policy: string }).policy).toBe("relaxed"); + expect((current.record as { policy: string }).policy).toBe("strict"); + expect(current.recordCid).not.toBe(historical.recordCid); + // A delayed com.atproto.sync.getRecord request would return only this + // final proof. The relaxed proof survives solely in the queued frame CAR. + expect(repo.currentRecordCid(PROFILE_NSID, SLUG)).toBe(current.recordCid); + }); + + it("rejects metadata substitution instead of trusting record JSON", async () => { + const repo = await SignedRepoFixture.create(DID); + const event = await repo.write(301, "create", PROFILE_NSID, SLUG, profile("strict")); + + await expect( + verifyHistoricalRecordEvent({ ...event, rev: "3mismatchedrev" }, repo.keypair), + ).rejects.toThrow("commit rev does not match"); + await expect( + verifyHistoricalRecordEvent({ ...event, recordCid: event.commitCid }, repo.keypair), + ).rejects.toThrow("record CID does not match"); + + const tampered = event.carBytes.slice(); + const finalByteIndex = tampered.length - 1; + tampered[finalByteIndex]! ^= 1; + await expect( + verifyHistoricalRecordEvent({ ...event, carBytes: tampered }, repo.keypair), + ).rejects.toThrow(); + }); + + it("rejects a commit signed by a different DID key", async () => { + const repo = await SignedRepoFixture.create(DID); + const event = await repo.write(351, "create", PROFILE_NSID, SLUG, profile("strict")); + const unrelatedKey = await P256PrivateKeyExportable.createKeypair(); + + await expect(verifyHistoricalRecordEvent(event, unrelatedKey)).rejects.toThrow( + "signature verification failed", + ); + }); + + it("retains delete events but fails closed without a historical non-inclusion proof", async () => { + const repo = await SignedRepoFixture.create(DID); + const prior = await repo.write(375, "create", PROFILE_NSID, SLUG, profile("strict")); + const [deleted] = extractHistoricalRecordEvents({ + repo: prior.did, + seq: 376, + rev: prior.rev, + commit: { $link: prior.commitCid }, + blocks: CBOR.toBytes(prior.carBytes), + ops: [{ action: "delete", cid: null, path: `${PROFILE_NSID}/${SLUG}` }], + tooBig: false, + }); + + expect(deleted).toMatchObject({ operation: "delete", recordCid: null, sequence: 376 }); + await expect(recoverOrderedHistory([deleted!], repo.keypair)).rejects.toThrow( + "historical delete/non-inclusion proof is not implemented", + ); + }); + + it("rejects a conflicting redelivery at the same single-relay ordering key", async () => { + const repo = await SignedRepoFixture.create(DID); + const event = await repo.write(401, "create", PROFILE_NSID, SLUG, profile("strict")); + const conflicting = cloneEvent(event); + const finalByteIndex = conflicting.carBytes.length - 1; + conflicting.carBytes[finalByteIndex]! ^= 1; + + await expect(recoverOrderedHistory([event, conflicting], repo.keypair)).rejects.toThrow( + "conflicting firehose redelivery at 401:0", + ); + }); +}); + +function profile(policy: "strict" | "relaxed"): Record { + return { $type: PROFILE_NSID, slug: SLUG, policy }; +} + +function cloneEvent(event: HistoricalRecordEvent): HistoricalRecordEvent { + return { + ...event, + orderingKey: [event.sequence, event.operationIndex], + carBytes: event.carBytes.slice(), + }; +} + +class SignedRepoFixture { + readonly keypair: P256PrivateKeyExportable; + private readonly store = new MemoryBlockStore(); + private readonly nodeStore = new NodeStore(this.store); + private readonly wrangler = new NodeWrangler(this.nodeStore); + private readonly records = new Map(); + private rootCid: string | null = null; + private commitCid: string | null = null; + + private constructor( + private readonly did: string, + keypair: P256PrivateKeyExportable, + ) { + this.keypair = keypair; + } + + static async create(did: string): Promise { + return new SignedRepoFixture(did, await P256PrivateKeyExportable.createKeypair()); + } + + currentRecordCid(collection: string, rkey: string): string | undefined { + return this.records.get(`${collection}/${rkey}`); + } + + async write( + sequence: number, + action: "create" | "update", + collection: string, + rkey: string, + record: Record, + ): Promise { + const path = `${collection}/${rkey}`; + const recordBytes = CBOR.encode(record); + const recordCid = await CID.create(CID.CODEC_DCBOR, recordBytes); + const recordLink = CID.toCidLink(recordCid); + await this.store.put(CID.toString(recordCid), recordBytes); + this.rootCid = await this.wrangler.putRecord(this.rootCid, path, recordLink); + this.records.set(path, recordLink.$link); + + const rev = revision(sequence); + const unsignedCommit = { + version: 3 as const, + did: this.did, + data: { $link: this.rootCid }, + rev, + prev: this.commitCid === null ? null : { $link: this.commitCid }, + }; + const signature = await this.keypair.sign(CBOR.encode(unsignedCommit)); + const commitBytes = CBOR.encode({ ...unsignedCommit, sig: CBOR.toBytes(signature) }); + const commitCid = await CID.create(CID.CODEC_DCBOR, commitBytes); + this.commitCid = CID.toString(commitCid); + await this.store.put(this.commitCid, commitBytes); + + const proofCids = await buildInclusionProof(this.nodeStore, this.rootCid, path); + const blockCids = [this.commitCid, ...proofCids, recordLink.$link]; + const blocks = await Promise.all( + blockCids.map(async (cid) => { + const bytes = await this.store.get(cid); + if (bytes === null) throw new Error(`fixture block missing: ${cid}`); + return { cid: CID.fromString(cid).bytes, data: bytes }; + }), + ); + const carChunks: Uint8Array[] = []; + for await (const chunk of CAR.writeCarStream([{ $link: this.commitCid }], blocks)) { + carChunks.push(chunk); + } + const carBytes = concat(carChunks); + const frame: FirehoseCommitFrame = { + repo: this.did, + seq: sequence, + rev, + commit: { $link: this.commitCid }, + blocks: CBOR.toBytes(carBytes), + ops: [{ action, cid: recordLink, path }], + tooBig: false, + }; + return extractHistoricalRecordEvents(frame)[0]!; + } +} + +function revision(sequence: number): string { + return `3${sequence.toString(32).padStart(12, "2")}`; +} + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const length = chunks.reduce((total, chunk) => total + chunk.length, 0); + const output = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.length; + } + return output; +}