From b009802c7e891c6232969434e9596ad3d7144cd9 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 8 Aug 2026 04:26:33 +0200 Subject: [PATCH 01/18] feat(agent): add exact active profile receiver --- .../agent/src/system-records/receiver-v1.ts | 294 ++++++++++++++++++ .../test/system-record-receiver-v1.test.ts | 262 ++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 packages/agent/src/system-records/receiver-v1.ts create mode 100644 packages/agent/test/system-record-receiver-v1.test.ts diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts new file mode 100644 index 0000000000..18b9645138 --- /dev/null +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -0,0 +1,294 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { + decodeOpaqueKaBundleV1, +} from '@origintrail-official/dkg-core'; +import { + buildAgentProfileVerificationClosureV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordStableKeyHashV1, + decodeSystemRecordInventoryRowV1, + encodeSystemRecordInventoryRowV1, + parseCanonicalOwnedSubjectTableObjectV1, + parseCanonicalSignedAgentProfileHeadEnvelopeV1, + verifySignedSystemRecordEnvelopeV1, + type AgentProfileActiveHeadObjectV1, + type AgentProfileAuthorityTransitionV1, + type AgentProfileForkResolutionV1, + type AgentProfileHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type Digest32V1, + type NetworkIdV1, + type OwnedSubjectTableObjectV1, + type SignedAgentProfileAuthorityTransitionEnvelopeV1, + type SignedAgentProfileForkResolutionEnvelopeV1, + type SignedAgentProfileHeadEnvelopeV1, + type SystemRecordInventoryRowV1, + type SystemRecordObjectKindV1, + type SystemRecordVerificationClosureObjectV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { + Quad, + SystemRecordApplyOutcomeV1, +} from '@origintrail-official/dkg-storage'; + +import { + cloneSystemRecordArtifactV1, + type SystemRecordArtifactRepositoryV1, +} from './artifact-v1.js'; + +export interface AgentProfileReceiverVerifiedBundleV1 { + /** Exact graphless quads parsed and authenticated from the supplied bundle. */ + readonly projectionQuads: readonly Readonly[]; +} + +/** Verified active-profile facts handed to the lifecycle-owned materializer bridge. */ +export interface AgentProfileReceiverCandidateV1 { + readonly head: AgentProfileActiveHeadObjectV1; + readonly envelope: SignedAgentProfileHeadEnvelopeV1; + readonly canonicalProjectionBytes: Uint8Array; + readonly projectionQuads: readonly Readonly[]; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; + readonly signal: AbortSignal; +} + +export interface CreateAgentProfileReceiverOptionsV1 { + readonly networkId: NetworkIdV1; + readonly artifacts: SystemRecordArtifactRepositoryV1; + /** + * Final authority verification, including bounded EIP-1271 handling when the + * envelope requests it. The receiver never owns a chain client or RPC queue. + */ + readonly verifyAuthorityEnvelope?: ( + envelope: + | SignedAgentProfileHeadEnvelopeV1 + | SignedAgentProfileAuthorityTransitionEnvelopeV1 + | SignedAgentProfileForkResolutionEnvelopeV1, + signal: AbortSignal, + ) => boolean | Promise; + /** + * Final graph-scoped publication/seal verification. Returning projection + * quads asserts that they were parsed from this exact canonical bundle. + */ + readonly verifyCurrentBundle: ( + head: AgentProfileActiveHeadObjectV1, + canonicalBundleBytes: Uint8Array, + signal: AbortSignal, + ) => AgentProfileReceiverVerifiedBundleV1 | Promise; + /** + * Lifecycle-owned bridge into the storage runtime. It mints and consumes the + * private replacement proof inside one structured call; no proof escapes. + */ + readonly consumeCandidate: ( + input: AgentProfileReceiverCandidateV1, + ) => SystemRecordApplyOutcomeV1 | Promise; + readonly nowMs?: () => number; +} + +export interface AgentProfileReceiverV1 { + /** + * Verify and apply one active inventory row. Inventory traversal, admission, + * continuation, caching, and retries remain owned by the caller. + */ + receiveActive( + row: SystemRecordInventoryRowV1, + signal: AbortSignal, + ): Promise; +} + +/** + * Default-unused exact active-record receiver. The only mutable state is scoped + * to one call, so abort and shutdown cannot strand a background operation. + */ +export function createAgentProfileReceiverV1( + options: CreateAgentProfileReceiverOptionsV1, +): AgentProfileReceiverV1 { + const networkId = options.networkId; + const artifacts = options.artifacts; + const verifyCurrentBundle = options.verifyCurrentBundle; + const consumeCandidate = options.consumeCandidate; + const nowMs = options.nowMs; + const verifyAuthorityEnvelope = options.verifyAuthorityEnvelope + ?? ((envelope: SignedAgentProfileHeadEnvelopeV1 + | SignedAgentProfileAuthorityTransitionEnvelopeV1 + | SignedAgentProfileForkResolutionEnvelopeV1) => + verifySignedSystemRecordEnvelopeV1< + AgentProfileHeadObjectV1 + | AgentProfileAuthorityTransitionV1 + | AgentProfileForkResolutionV1 + >(envelope)); + + return Object.freeze({ + async receiveActive( + inputRow: SystemRecordInventoryRowV1, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const row = canonicalInventoryRow(networkId, inputRow); + if (row.tombstone || row.quarantined || row.conflictEvidenceDigest !== undefined) { + throw new Error('active profile receiver requires an ordinary active inventory row'); + } + + let verifiedBundle: Readonly<{ + projectionQuads: readonly Readonly[]; + canonicalProjectionBytes: Uint8Array; + }> | undefined; + const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { + nowMs: receiverNowMs(nowMs?.() ?? Date.now()), + resolve: async (reference) => { + signal.throwIfAborted(); + const artifact = await artifacts.resolve({ + type: 'object', + objectKind: reference.objectKind, + objectDigest: reference.digest, + }, signal); + signal.throwIfAborted(); + if (artifact === null) return undefined; + const owned = cloneSystemRecordArtifactV1(artifact); + if (owned.objectKind !== reference.objectKind || owned.objectDigest !== reference.digest) { + throw new Error('system-record repository returned a different closure artifact'); + } + return Object.freeze({ + objectKind: owned.objectKind, + digest: owned.objectDigest, + canonicalBytes: owned.canonicalBytes, + }); + }, + verifyAuthorityEnvelope: async (envelope) => { + signal.throwIfAborted(); + const verified = await verifyAuthorityEnvelope(envelope, signal); + signal.throwIfAborted(); + return verified === true; + }, + verifyCurrentBundle: async (head, canonicalBundleBytes) => { + signal.throwIfAborted(); + const result = await verifyCurrentBundle( + head, + Uint8Array.from(canonicalBundleBytes), + signal, + ); + signal.throwIfAborted(); + const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); + verifiedBundle = Object.freeze({ + ...snapshotVerifiedBundle(result), + canonicalProjectionBytes: Uint8Array.from(decoded.projectionBytes), + }); + return true; + }, + }); + signal.throwIfAborted(); + + const headArtifact = requiredArtifact( + closure.objects, + 'agent-profile-head', + row.headDigest, + ); + const envelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( + headArtifact.canonicalBytes, + ); + const head = envelope.object; + assertRowBindsHead(networkId, row, envelope); + if (head.state !== 'active' || verifiedBundle === undefined) { + throw new Error('active profile receiver resolved a non-active verification closure'); + } + const verifiedAuthoritySummary = closure.authoritySummary; + + const subjectTableArtifact = await artifacts.resolve({ + type: 'object', + objectKind: 'owned-subject-table', + objectDigest: head.ownedSubjectTableDigest, + }, signal); + signal.throwIfAborted(); + if (subjectTableArtifact === null + || subjectTableArtifact.objectKind !== 'owned-subject-table' + || subjectTableArtifact.objectDigest !== head.ownedSubjectTableDigest) { + throw new Error('active profile receiver is missing its exact owned-subject table'); + } + const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( + head.rootSubject, + cloneSystemRecordArtifactV1(subjectTableArtifact).canonicalBytes, + ); + if (computeOwnedSubjectTableDigestV1(head.rootSubject, ownedSubjectTable) + !== head.ownedSubjectTableDigest + || BigInt(ownedSubjectTable.length) !== BigInt(head.ownedSubjectCount)) { + throw new Error('active profile owned-subject table does not bind the verified head'); + } + + const outcome = await consumeCandidate(Object.freeze({ + head, + envelope, + canonicalProjectionBytes: verifiedBundle.canonicalProjectionBytes, + projectionQuads: verifiedBundle.projectionQuads, + ownedSubjectTable, + verifiedAuthoritySummary, + signal, + })); + // Atomic apply is the point of no return. A cancellation that arrives + // after the storage closure returns must not hide a committed outcome and + // make the caller retry it as if nothing happened. + return outcome; + }, + }); +} + +function canonicalInventoryRow( + networkId: NetworkIdV1, + row: SystemRecordInventoryRowV1, +): SystemRecordInventoryRowV1 { + return decodeSystemRecordInventoryRowV1( + networkId, + encodeSystemRecordInventoryRowV1(networkId, row), + ); +} + +function assertRowBindsHead( + networkId: NetworkIdV1, + row: SystemRecordInventoryRowV1, + envelope: SignedAgentProfileHeadEnvelopeV1, +): void { + const head = envelope.object; + if (head.networkId !== networkId + || head.peerId !== row.peerId + || computeSystemRecordStableKeyHashV1(networkId, row.peerId) !== row.stableKeyHash + || head.authoritySequence !== row.authoritySequence + || head.version !== row.version + || envelope.objectDigest !== row.headDigest + || (head.state === 'tombstone') !== row.tombstone) { + throw new Error('inventory row does not bind the verified agent-profile head'); + } +} + +function requiredArtifact( + artifacts: readonly SystemRecordVerificationClosureObjectV1[], + objectKind: SystemRecordObjectKindV1, + objectDigest: Digest32V1, +): SystemRecordVerificationClosureObjectV1 { + const artifact = artifacts.find( + (candidate) => candidate.objectKind === objectKind && candidate.digest === objectDigest, + ); + if (artifact === undefined) { + throw new Error(`verification closure did not retain ${objectKind}:${objectDigest}`); + } + return artifact; +} + +function snapshotVerifiedBundle(value: AgentProfileReceiverVerifiedBundleV1): AgentProfileReceiverVerifiedBundleV1 { + if (value === null || typeof value !== 'object' || !Array.isArray(value.projectionQuads)) { + throw new Error('bundle verifier returned an invalid projection'); + } + const projectionQuads = value.projectionQuads.map((quad) => Object.freeze({ + subject: quad.subject, + predicate: quad.predicate, + object: quad.object, + graph: quad.graph, + })); + return Object.freeze({ projectionQuads: Object.freeze(projectionQuads) }); +} + +function receiverNowMs(value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error('agent-profile receiver clock returned an invalid value'); + } + return value; +} diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts new file mode 100644 index 0000000000..e79f289b0b --- /dev/null +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { decodeOpaqueKaBundleV1 } from '@origintrail-official/dkg-core'; + +import { + computeSystemRecordStableKeyHashV1, + type SystemRecordInventoryRowV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import { parseNQuads } from '../src/dkg-agent-utils.js'; +import { + createAgentProfileReceiverV1, +} from '../src/system-records/receiver-v1.js'; +import { + createFixtureAgentProfileProducerV1, + DEPLOYMENT, + NETWORK, + produce, + producerFixture, + PRODUCER_FIXTURE_NOW_MS, +} from './support/agent-profile-producer-v1-fixture.js'; + +async function publishedFixture() { + const fixture = await producerFixture(); + const producer = createFixtureAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => undefined, + install: () => undefined, + }); + await produce(producer, fixture.prepared, fixture.publication); + const envelope = fixture.store.snapshot().currentHead; + if (envelope === null) throw new Error('fixture producer did not publish a head'); + const head = envelope.object; + const row: SystemRecordInventoryRowV1 = Object.freeze({ + stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), + peerId: head.peerId, + authoritySequence: head.authoritySequence, + version: head.version, + headDigest: envelope.objectDigest, + tombstone: false, + quarantined: false, + }); + return { ...fixture, envelope, row }; +} + +function verifyFixtureBundle(_head: unknown, bundleBytes: Uint8Array) { + const { projectionBytes } = decodeOpaqueKaBundleV1(bundleBytes); + return Object.freeze({ + projectionQuads: Object.freeze(parseNQuads(new TextDecoder().decode(projectionBytes))), + }); +} + +describe('agent-profile system-record active receiver', () => { + it('verifies the exact closure and submits one immutable active candidate', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(async () => ({ + outcome: 'applied' as const, + stateRevision: '1', + appliedStateDigest: `0x${'a'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(consumeCandidate).toHaveBeenCalledTimes(1); + const candidate = consumeCandidate.mock.calls[0]![0]; + expect(candidate.head).toEqual(fixture.envelope.object); + expect(candidate.envelope).toEqual(fixture.envelope); + expect([...candidate.projectionQuads].sort(compareQuad)) + .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); + expect(candidate.ownedSubjectTable).toContain(fixture.prepared.rootEntity); + expect(candidate.canonicalProjectionBytes.byteLength).toBeGreaterThan(0); + }); + + it('fails closed when the exact owned-subject table is unavailable', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: Object.freeze({ + resolve: (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'owned-subject-table' + ? Promise.resolve(null) + : fixture.store.resolve(lookup, signal), + }), + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/owned-subject table/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + + it('returns a committed apply outcome when cancellation arrives at the point of no return', async () => { + const fixture = await publishedFixture(); + const controller = new AbortController(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate: async () => { + controller.abort(new Error('late stop')); + return { + outcome: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + }; + }, + }); + + await expect(receiver.receiveActive(fixture.row, controller.signal)).resolves.toEqual({ + outcome: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + }); + }); + + it('honors a caller abort before resolving any artifact', async () => { + const resolve = vi.fn(); + const controller = new AbortController(); + controller.abort(new Error('test stop')); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + verifyCurrentBundle: vi.fn(), + consumeCandidate: vi.fn(), + }); + const row: SystemRecordInventoryRowV1 = { + stableKeyHash: `0x${'a'.repeat(64)}`, + peerId: 'unused', + authoritySequence: '0', + version: '0', + headDigest: `0x${'b'.repeat(64)}`, + tombstone: false, + quarantined: false, + }; + + await expect(receiver.receiveActive(row, controller.signal)).rejects.toThrow('test stop'); + expect(resolve).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'tombstone', + patch: { tombstone: true }, + }, + { + label: 'quarantined', + patch: { + quarantined: true, + conflictEvidenceDigest: `0x${'d'.repeat(64)}`, + }, + }, + ])('rejects a $label row before fetching closure artifacts', async ({ patch }) => { + const fixture = await publishedFixture(); + const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate: vi.fn(), + }); + + await expect(receiver.receiveActive( + Object.freeze({ ...fixture.row, ...patch }), + new AbortController().signal, + )).rejects.toThrow(/ordinary active inventory row/); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('fails closed when the verified head does not bind the inventory version', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive( + Object.freeze({ ...fixture.row, version: '1' }), + new AbortController().signal, + )).rejects.toThrow(/inventory row does not bind/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + + it('fails closed when final authority verification refuses the closure', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyAuthorityEnvelope: () => false, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).rejects.toThrow(/authority verification failed/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + + it('captures lifecycle dependencies once instead of rereading mutable options', async () => { + const fixture = await publishedFixture(); + const verifyCurrentBundle = vi.fn(verifyFixtureBundle); + const consumeCandidate = vi.fn(async () => ({ + outcome: 'applied' as const, + stateRevision: '3', + appliedStateDigest: `0x${'e'.repeat(64)}`, + })); + const mutable = { + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + consumeCandidate, + }; + const receiver = createAgentProfileReceiverV1(mutable); + mutable.verifyCurrentBundle = vi.fn(() => { + throw new Error('mutated verifier was observed'); + }); + mutable.consumeCandidate = vi.fn(() => { + throw new Error('mutated materializer was observed'); + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).resolves.toMatchObject({ outcome: 'applied', stateRevision: '3' }); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(consumeCandidate).toHaveBeenCalledTimes(1); + }); +}); + +function compareQuad( + left: { subject: string; predicate: string; object: string; graph: string }, + right: { subject: string; predicate: string; object: string; graph: string }, +): number { + return left.subject.localeCompare(right.subject) + || left.predicate.localeCompare(right.predicate) + || left.object.localeCompare(right.object) + || left.graph.localeCompare(right.graph); +} From 05dea75fbfa24ce93934c62ebdb517119eaf09d1 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 16:19:47 +0200 Subject: [PATCH 02/18] fix(agent): capture receiver repository dependency --- packages/agent/src/system-records/receiver-v1.ts | 15 +++++++++------ .../agent/test/system-record-receiver-v1.test.ts | 8 +++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 18b9645138..36e9501494 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -105,7 +105,7 @@ export function createAgentProfileReceiverV1( options: CreateAgentProfileReceiverOptionsV1, ): AgentProfileReceiverV1 { const networkId = options.networkId; - const artifacts = options.artifacts; + const resolveArtifact = options.artifacts.resolve.bind(options.artifacts); const verifyCurrentBundle = options.verifyCurrentBundle; const consumeCandidate = options.consumeCandidate; const nowMs = options.nowMs; @@ -138,7 +138,7 @@ export function createAgentProfileReceiverV1( nowMs: receiverNowMs(nowMs?.() ?? Date.now()), resolve: async (reference) => { signal.throwIfAborted(); - const artifact = await artifacts.resolve({ + const artifact = await resolveArtifact({ type: 'object', objectKind: reference.objectKind, objectDigest: reference.digest, @@ -194,20 +194,23 @@ export function createAgentProfileReceiverV1( } const verifiedAuthoritySummary = closure.authoritySummary; - const subjectTableArtifact = await artifacts.resolve({ + const resolvedSubjectTableArtifact = await resolveArtifact({ type: 'object', objectKind: 'owned-subject-table', objectDigest: head.ownedSubjectTableDigest, }, signal); signal.throwIfAborted(); - if (subjectTableArtifact === null - || subjectTableArtifact.objectKind !== 'owned-subject-table' + if (resolvedSubjectTableArtifact === null) { + throw new Error('active profile receiver is missing its exact owned-subject table'); + } + const subjectTableArtifact = cloneSystemRecordArtifactV1(resolvedSubjectTableArtifact); + if (subjectTableArtifact.objectKind !== 'owned-subject-table' || subjectTableArtifact.objectDigest !== head.ownedSubjectTableDigest) { throw new Error('active profile receiver is missing its exact owned-subject table'); } const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( head.rootSubject, - cloneSystemRecordArtifactV1(subjectTableArtifact).canonicalBytes, + subjectTableArtifact.canonicalBytes, ); if (computeOwnedSubjectTableDigestV1(head.rootSubject, ownedSubjectTable) !== head.ownedSubjectTableDigest diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index e79f289b0b..5e1b567728 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -227,9 +227,11 @@ describe('agent-profile system-record active receiver', () => { stateRevision: '3', appliedStateDigest: `0x${'e'.repeat(64)}`, })); + const resolveArtifact = vi.fn(fixture.store.resolve.bind(fixture.store)); + const repository = { resolve: resolveArtifact }; const mutable = { networkId: NETWORK, - artifacts: fixture.store, + artifacts: repository, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, consumeCandidate, @@ -241,6 +243,9 @@ describe('agent-profile system-record active receiver', () => { mutable.consumeCandidate = vi.fn(() => { throw new Error('mutated materializer was observed'); }); + repository.resolve = vi.fn(() => { + throw new Error('mutated repository was observed'); + }); await expect(receiver.receiveActive( fixture.row, @@ -248,6 +253,7 @@ describe('agent-profile system-record active receiver', () => { )).resolves.toMatchObject({ outcome: 'applied', stateRevision: '3' }); expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); expect(consumeCandidate).toHaveBeenCalledTimes(1); + expect(resolveArtifact).toHaveBeenCalled(); }); }); From 2a4cb3001de957d07a56c3064684fdeb7ee9eac9 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 16:22:28 +0200 Subject: [PATCH 03/18] fix(agent): cap receiver artifacts before copying --- .../agent/src/system-records/receiver-v1.ts | 47 +++++++++++++++---- .../test/system-record-receiver-v1.test.ts | 36 ++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 36e9501494..1dce176c82 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -5,12 +5,14 @@ import { } from '@origintrail-official/dkg-core'; import { buildAgentProfileVerificationClosureV1, + copyBoundedSystemRecordBytesV1, computeOwnedSubjectTableDigestV1, computeSystemRecordStableKeyHashV1, decodeSystemRecordInventoryRowV1, encodeSystemRecordInventoryRowV1, parseCanonicalOwnedSubjectTableObjectV1, parseCanonicalSignedAgentProfileHeadEnvelopeV1, + SYSTEM_RECORD_OBJECT_CAPS_V1, verifySignedSystemRecordEnvelopeV1, type AgentProfileActiveHeadObjectV1, type AgentProfileAuthorityTransitionV1, @@ -33,8 +35,8 @@ import type { } from '@origintrail-official/dkg-storage'; import { - cloneSystemRecordArtifactV1, type SystemRecordArtifactRepositoryV1, + type SystemRecordArtifactV1, } from './artifact-v1.js'; export interface AgentProfileReceiverVerifiedBundleV1 { @@ -145,10 +147,12 @@ export function createAgentProfileReceiverV1( }, signal); signal.throwIfAborted(); if (artifact === null) return undefined; - const owned = cloneSystemRecordArtifactV1(artifact); - if (owned.objectKind !== reference.objectKind || owned.objectDigest !== reference.digest) { - throw new Error('system-record repository returned a different closure artifact'); - } + const owned = snapshotExpectedArtifactV1( + artifact, + reference.objectKind, + reference.digest, + 'closure artifact', + ); return Object.freeze({ objectKind: owned.objectKind, digest: owned.objectDigest, @@ -203,11 +207,12 @@ export function createAgentProfileReceiverV1( if (resolvedSubjectTableArtifact === null) { throw new Error('active profile receiver is missing its exact owned-subject table'); } - const subjectTableArtifact = cloneSystemRecordArtifactV1(resolvedSubjectTableArtifact); - if (subjectTableArtifact.objectKind !== 'owned-subject-table' - || subjectTableArtifact.objectDigest !== head.ownedSubjectTableDigest) { - throw new Error('active profile receiver is missing its exact owned-subject table'); - } + const subjectTableArtifact = snapshotExpectedArtifactV1( + resolvedSubjectTableArtifact, + 'owned-subject-table', + head.ownedSubjectTableDigest, + 'owned-subject table', + ); const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( head.rootSubject, subjectTableArtifact.canonicalBytes, @@ -276,6 +281,28 @@ function requiredArtifact( return artifact; } +function snapshotExpectedArtifactV1( + input: SystemRecordArtifactV1, + expectedKind: SystemRecordObjectKindV1, + expectedDigest: Digest32V1, + label: string, +): SystemRecordArtifactV1 { + const objectKind = input.objectKind; + const objectDigest = input.objectDigest; + if (objectKind !== expectedKind || objectDigest !== expectedDigest) { + throw new Error(`system-record repository returned a different ${label}`); + } + return Object.freeze({ + objectKind, + objectDigest, + canonicalBytes: copyBoundedSystemRecordBytesV1( + input.canonicalBytes, + SYSTEM_RECORD_OBJECT_CAPS_V1[expectedKind], + label, + ), + }); +} + function snapshotVerifiedBundle(value: AgentProfileReceiverVerifiedBundleV1): AgentProfileReceiverVerifiedBundleV1 { if (value === null || typeof value !== 'object' || !Array.isArray(value.projectionQuads)) { throw new Error('bundle verifier returned an invalid projection'); diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 5e1b567728..83d15b2b30 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -4,6 +4,7 @@ import { decodeOpaqueKaBundleV1 } from '@origintrail-official/dkg-core'; import { computeSystemRecordStableKeyHashV1, + SYSTEM_RECORD_OBJECT_CAPS_V1, type SystemRecordInventoryRowV1, } from '@origintrail-official/dkg-core/system-record-v1'; @@ -219,6 +220,41 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('rejects an oversized artifact before invoking typed-array copy hooks', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + class CopyTrapBytes extends Uint8Array { + override *[Symbol.iterator](): ArrayIterator { + throw new Error('unbounded artifact copy ran before the cap'); + } + } + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { + resolve: async (lookup, signal) => { + const artifact = await fixture.store.resolve(lookup, signal); + if (artifact === null || lookup.type !== 'object' + || lookup.objectKind !== 'agent-profile-head') return artifact; + return Object.freeze({ + ...artifact, + canonicalBytes: new CopyTrapBytes( + SYSTEM_RECORD_OBJECT_CAPS_V1['agent-profile-head'] + 1, + ), + }); + }, + }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).rejects.toThrow(/closure artifact exceeds/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('captures lifecycle dependencies once instead of rereading mutable options', async () => { const fixture = await publishedFixture(); const verifyCurrentBundle = vi.fn(verifyFixtureBundle); From 7542bf7817ff523f7d4e4b90254802e1f539b0ac Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 16:36:23 +0200 Subject: [PATCH 04/18] perf(agent): reject unbound profile rows before closure fetch --- .../agent/src/system-records/receiver-v1.ts | 129 ++++++++---------- .../test/system-record-receiver-v1.test.ts | 8 +- 2 files changed, 64 insertions(+), 73 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 1dce176c82..a351c81c31 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -27,7 +27,6 @@ import { type SignedAgentProfileHeadEnvelopeV1, type SystemRecordInventoryRowV1, type SystemRecordObjectKindV1, - type SystemRecordVerificationClosureObjectV1, } from '@origintrail-official/dkg-core/system-record-v1'; import type { Quad, @@ -136,68 +135,70 @@ export function createAgentProfileReceiverV1( projectionQuads: readonly Readonly[]; canonicalProjectionBytes: Uint8Array; }> | undefined; - const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { - nowMs: receiverNowMs(nowMs?.() ?? Date.now()), - resolve: async (reference) => { - signal.throwIfAborted(); - const artifact = await resolveArtifact({ - type: 'object', - objectKind: reference.objectKind, - objectDigest: reference.digest, - }, signal); - signal.throwIfAborted(); - if (artifact === null) return undefined; - const owned = snapshotExpectedArtifactV1( - artifact, - reference.objectKind, - reference.digest, - 'closure artifact', - ); - return Object.freeze({ - objectKind: owned.objectKind, - digest: owned.objectDigest, - canonicalBytes: owned.canonicalBytes, - }); - }, - verifyAuthorityEnvelope: async (envelope) => { - signal.throwIfAborted(); - const verified = await verifyAuthorityEnvelope(envelope, signal); - signal.throwIfAborted(); - return verified === true; - }, - verifyCurrentBundle: async (head, canonicalBundleBytes) => { - signal.throwIfAborted(); - const result = await verifyCurrentBundle( - head, - Uint8Array.from(canonicalBundleBytes), - signal, - ); - signal.throwIfAborted(); - const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); - verifiedBundle = Object.freeze({ - ...snapshotVerifiedBundle(result), - canonicalProjectionBytes: Uint8Array.from(decoded.projectionBytes), - }); - return true; - }, - }); + let currentEnvelope: SignedAgentProfileHeadEnvelopeV1 | undefined; + const { authoritySummary: verifiedAuthoritySummary } = + await buildAgentProfileVerificationClosureV1(row.headDigest, { + nowMs: receiverNowMs(nowMs?.() ?? Date.now()), + resolve: async (reference) => { + signal.throwIfAborted(); + const artifact = await resolveArtifact({ + type: 'object', + objectKind: reference.objectKind, + objectDigest: reference.digest, + }, signal); + signal.throwIfAborted(); + if (artifact === null) return undefined; + const owned = snapshotExpectedArtifactV1( + artifact, + reference.objectKind, + reference.digest, + 'closure artifact', + ); + if (reference.objectKind === 'agent-profile-head' + && reference.digest === row.headDigest) { + currentEnvelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( + owned.canonicalBytes, + ); + assertRowBindsHead(networkId, row, currentEnvelope); + } + return Object.freeze({ + objectKind: owned.objectKind, + digest: owned.objectDigest, + canonicalBytes: owned.canonicalBytes, + }); + }, + verifyAuthorityEnvelope: async (envelope) => { + signal.throwIfAborted(); + const verified = await verifyAuthorityEnvelope(envelope, signal); + signal.throwIfAborted(); + return verified === true; + }, + verifyCurrentBundle: async (head, canonicalBundleBytes) => { + signal.throwIfAborted(); + const result = await verifyCurrentBundle( + head, + Uint8Array.from(canonicalBundleBytes), + signal, + ); + signal.throwIfAborted(); + const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); + verifiedBundle = Object.freeze({ + ...snapshotVerifiedBundle(result), + canonicalProjectionBytes: Uint8Array.from(decoded.projectionBytes), + }); + return true; + }, + }); signal.throwIfAborted(); - const headArtifact = requiredArtifact( - closure.objects, - 'agent-profile-head', - row.headDigest, - ); - const envelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( - headArtifact.canonicalBytes, - ); + const envelope = currentEnvelope; + if (envelope === undefined) { + throw new Error('verification closure did not retain its current agent-profile head'); + } const head = envelope.object; - assertRowBindsHead(networkId, row, envelope); if (head.state !== 'active' || verifiedBundle === undefined) { throw new Error('active profile receiver resolved a non-active verification closure'); } - const verifiedAuthoritySummary = closure.authoritySummary; - const resolvedSubjectTableArtifact = await resolveArtifact({ type: 'object', objectKind: 'owned-subject-table', @@ -267,20 +268,6 @@ function assertRowBindsHead( } } -function requiredArtifact( - artifacts: readonly SystemRecordVerificationClosureObjectV1[], - objectKind: SystemRecordObjectKindV1, - objectDigest: Digest32V1, -): SystemRecordVerificationClosureObjectV1 { - const artifact = artifacts.find( - (candidate) => candidate.objectKind === objectKind && candidate.digest === objectDigest, - ); - if (artifact === undefined) { - throw new Error(`verification closure did not retain ${objectKind}:${objectDigest}`); - } - return artifact; -} - function snapshotExpectedArtifactV1( input: SystemRecordArtifactV1, expectedKind: SystemRecordObjectKindV1, diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 83d15b2b30..32577b5a27 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -186,11 +186,13 @@ describe('agent-profile system-record active receiver', () => { it('fails closed when the verified head does not bind the inventory version', async () => { const fixture = await publishedFixture(); const consumeCandidate = vi.fn(); + const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); + const verifyCurrentBundle = vi.fn(verifyFixtureBundle); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, - artifacts: fixture.store, + artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle, consumeCandidate, }); @@ -199,6 +201,8 @@ describe('agent-profile system-record active receiver', () => { new AbortController().signal, )).rejects.toThrow(/inventory row does not bind/); expect(consumeCandidate).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledTimes(1); + expect(verifyCurrentBundle).not.toHaveBeenCalled(); }); it('fails closed when final authority verification refuses the closure', async () => { From 627e04dca6fd8644cb7ff89cfbf109617387b271 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 16:58:41 +0200 Subject: [PATCH 05/18] refactor(agent): return verified receiver closure facts --- .../agent/src/system-records/receiver-v1.ts | 186 +++++++++++------- .../test/system-record-receiver-v1.test.ts | 16 +- 2 files changed, 131 insertions(+), 71 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index a351c81c31..7b5f94ab22 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -51,7 +51,6 @@ export interface AgentProfileReceiverCandidateV1 { readonly projectionQuads: readonly Readonly[]; readonly ownedSubjectTable: OwnedSubjectTableObjectV1; readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; - readonly signal: AbortSignal; } export interface CreateAgentProfileReceiverOptionsV1 { @@ -83,6 +82,7 @@ export interface CreateAgentProfileReceiverOptionsV1 { */ readonly consumeCandidate: ( input: AgentProfileReceiverCandidateV1, + signal: AbortSignal, ) => SystemRecordApplyOutcomeV1 | Promise; readonly nowMs?: () => number; } @@ -131,74 +131,22 @@ export function createAgentProfileReceiverV1( throw new Error('active profile receiver requires an ordinary active inventory row'); } - let verifiedBundle: Readonly<{ - projectionQuads: readonly Readonly[]; - canonicalProjectionBytes: Uint8Array; - }> | undefined; - let currentEnvelope: SignedAgentProfileHeadEnvelopeV1 | undefined; - const { authoritySummary: verifiedAuthoritySummary } = - await buildAgentProfileVerificationClosureV1(row.headDigest, { - nowMs: receiverNowMs(nowMs?.() ?? Date.now()), - resolve: async (reference) => { - signal.throwIfAborted(); - const artifact = await resolveArtifact({ - type: 'object', - objectKind: reference.objectKind, - objectDigest: reference.digest, - }, signal); - signal.throwIfAborted(); - if (artifact === null) return undefined; - const owned = snapshotExpectedArtifactV1( - artifact, - reference.objectKind, - reference.digest, - 'closure artifact', - ); - if (reference.objectKind === 'agent-profile-head' - && reference.digest === row.headDigest) { - currentEnvelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( - owned.canonicalBytes, - ); - assertRowBindsHead(networkId, row, currentEnvelope); - } - return Object.freeze({ - objectKind: owned.objectKind, - digest: owned.objectDigest, - canonicalBytes: owned.canonicalBytes, - }); - }, - verifyAuthorityEnvelope: async (envelope) => { - signal.throwIfAborted(); - const verified = await verifyAuthorityEnvelope(envelope, signal); - signal.throwIfAborted(); - return verified === true; - }, - verifyCurrentBundle: async (head, canonicalBundleBytes) => { - signal.throwIfAborted(); - const result = await verifyCurrentBundle( - head, - Uint8Array.from(canonicalBundleBytes), - signal, - ); - signal.throwIfAborted(); - const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); - verifiedBundle = Object.freeze({ - ...snapshotVerifiedBundle(result), - canonicalProjectionBytes: Uint8Array.from(decoded.projectionBytes), - }); - return true; - }, - }); + const { + envelope, + verifiedBundle, + verifiedAuthoritySummary, + } = await verifyActiveProfileClosureForRowV1({ + networkId, + row, + signal, + nowMs: receiverNowMs(nowMs?.() ?? Date.now()), + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + }); signal.throwIfAborted(); - const envelope = currentEnvelope; - if (envelope === undefined) { - throw new Error('verification closure did not retain its current agent-profile head'); - } const head = envelope.object; - if (head.state !== 'active' || verifiedBundle === undefined) { - throw new Error('active profile receiver resolved a non-active verification closure'); - } const resolvedSubjectTableArtifact = await resolveArtifact({ type: 'object', objectKind: 'owned-subject-table', @@ -231,8 +179,7 @@ export function createAgentProfileReceiverV1( projectionQuads: verifiedBundle.projectionQuads, ownedSubjectTable, verifiedAuthoritySummary, - signal, - })); + }), signal); // Atomic apply is the point of no return. A cancellation that arrives // after the storage closure returns must not hide a committed outcome and // make the caller retry it as if nothing happened. @@ -241,6 +188,109 @@ export function createAgentProfileReceiverV1( }); } +interface VerifiedActiveProfileClosureV1 { + readonly envelope: SignedAgentProfileHeadEnvelopeV1 & { + readonly object: AgentProfileActiveHeadObjectV1; + }; + readonly verifiedBundle: Readonly<{ + readonly projectionQuads: readonly Readonly[]; + readonly canonicalProjectionBytes: Uint8Array; + }>; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; +} + +interface VerifyActiveProfileClosureOptionsV1 { + readonly networkId: NetworkIdV1; + readonly row: SystemRecordInventoryRowV1; + readonly signal: AbortSignal; + readonly nowMs: number; + readonly resolveArtifact: SystemRecordArtifactRepositoryV1['resolve']; + readonly verifyAuthorityEnvelope: NonNullable< + CreateAgentProfileReceiverOptionsV1['verifyAuthorityEnvelope'] + >; + readonly verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle']; +} + +async function verifyActiveProfileClosureForRowV1( + options: VerifyActiveProfileClosureOptionsV1, +): Promise { + const { + networkId, + row, + signal, + nowMs, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + } = options; + let verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle'] | undefined; + let currentEnvelope: SignedAgentProfileHeadEnvelopeV1 | undefined; + const { authoritySummary: verifiedAuthoritySummary } = + await buildAgentProfileVerificationClosureV1(row.headDigest, { + nowMs, + resolve: async (reference) => { + signal.throwIfAborted(); + const artifact = await resolveArtifact({ + type: 'object', + objectKind: reference.objectKind, + objectDigest: reference.digest, + }, signal); + signal.throwIfAborted(); + if (artifact === null) return undefined; + const owned = snapshotExpectedArtifactV1( + artifact, + reference.objectKind, + reference.digest, + 'closure artifact', + ); + if (reference.objectKind === 'agent-profile-head' + && reference.digest === row.headDigest) { + const parsed = parseCanonicalSignedAgentProfileHeadEnvelopeV1(owned.canonicalBytes); + assertRowBindsHead(networkId, row, parsed); + currentEnvelope = parsed; + } + return Object.freeze({ + objectKind: owned.objectKind, + digest: owned.objectDigest, + canonicalBytes: owned.canonicalBytes, + }); + }, + verifyAuthorityEnvelope: async (envelope) => { + signal.throwIfAborted(); + const verified = await verifyAuthorityEnvelope(envelope, signal); + signal.throwIfAborted(); + return verified === true; + }, + verifyCurrentBundle: async (head, canonicalBundleBytes) => { + signal.throwIfAborted(); + const result = await verifyCurrentBundle( + head, + Uint8Array.from(canonicalBundleBytes), + signal, + ); + signal.throwIfAborted(); + const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); + verifiedBundle = Object.freeze({ + ...snapshotVerifiedBundle(result), + canonicalProjectionBytes: Uint8Array.from(decoded.projectionBytes), + }); + return true; + }, + }); + const envelope = currentEnvelope; + if (envelope === undefined) { + throw new Error('verification closure did not retain its current agent-profile head'); + } + if (envelope.object.state !== 'active' || verifiedBundle === undefined) { + throw new Error('active profile receiver resolved a non-active verification closure'); + } + return Object.freeze({ + envelope: envelope as VerifiedActiveProfileClosureV1['envelope'], + verifiedBundle, + verifiedAuthoritySummary, + }); +} + function canonicalInventoryRow( networkId: NetworkIdV1, row: SystemRecordInventoryRowV1, diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 32577b5a27..b2cb2695a1 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -71,7 +71,8 @@ describe('agent-profile system-record active receiver', () => { consumeCandidate, }); - await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + const signal = new AbortController().signal; + await expect(receiver.receiveActive(fixture.row, signal)) .resolves.toMatchObject({ outcome: 'applied' }); expect(consumeCandidate).toHaveBeenCalledTimes(1); const candidate = consumeCandidate.mock.calls[0]![0]; @@ -81,6 +82,8 @@ describe('agent-profile system-record active receiver', () => { .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); expect(candidate.ownedSubjectTable).toContain(fixture.prepared.rootEntity); expect(candidate.canonicalProjectionBytes.byteLength).toBeGreaterThan(0); + expect(candidate).not.toHaveProperty('signal'); + expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); }); it('fails closed when the exact owned-subject table is unavailable', async () => { @@ -157,6 +160,7 @@ describe('agent-profile system-record active receiver', () => { { label: 'tombstone', patch: { tombstone: true }, + error: /ordinary active inventory row/, }, { label: 'quarantined', @@ -164,8 +168,14 @@ describe('agent-profile system-record active receiver', () => { quarantined: true, conflictEvidenceDigest: `0x${'d'.repeat(64)}`, }, + error: /ordinary active inventory row/, + }, + { + label: 'conflict evidence', + patch: { conflictEvidenceDigest: `0x${'d'.repeat(64)}` }, + error: /conflict evidence may appear only on quarantined rows|ordinary active inventory row/, }, - ])('rejects a $label row before fetching closure artifacts', async ({ patch }) => { + ])('rejects a $label row before fetching closure artifacts', async ({ patch, error }) => { const fixture = await publishedFixture(); const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); const receiver = createAgentProfileReceiverV1({ @@ -179,7 +189,7 @@ describe('agent-profile system-record active receiver', () => { await expect(receiver.receiveActive( Object.freeze({ ...fixture.row, ...patch }), new AbortController().signal, - )).rejects.toThrow(/ordinary active inventory row/); + )).rejects.toThrow(error); expect(resolve).not.toHaveBeenCalled(); }); From 4b7c129abe6f81224aac145016c53e669fe3b8ca Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 17:24:28 +0200 Subject: [PATCH 06/18] fix(agent): bind verified receiver projection facts --- .../agent/src/system-records/receiver-v1.ts | 57 ++++++++++------ .../test/system-record-receiver-v1.test.ts | 67 ++++++++++++++++++- 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 7b5f94ab22..2c608efd04 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -39,7 +39,9 @@ import { } from './artifact-v1.js'; export interface AgentProfileReceiverVerifiedBundleV1 { - /** Exact graphless quads parsed and authenticated from the supplied bundle. */ + /** Exact canonical projection bytes parsed and authenticated from the supplied bundle. */ + readonly canonicalProjectionBytes: Uint8Array; + /** Exact graphless quads parsed from canonicalProjectionBytes. */ readonly projectionQuads: readonly Readonly[]; } @@ -67,10 +69,7 @@ export interface CreateAgentProfileReceiverOptionsV1 { | SignedAgentProfileForkResolutionEnvelopeV1, signal: AbortSignal, ) => boolean | Promise; - /** - * Final graph-scoped publication/seal verification. Returning projection - * quads asserts that they were parsed from this exact canonical bundle. - */ + /** Final graph-scoped publication/seal verification of one coherent projection. */ readonly verifyCurrentBundle: ( head: AgentProfileActiveHeadObjectV1, canonicalBundleBytes: Uint8Array, @@ -224,8 +223,7 @@ async function verifyActiveProfileClosureForRowV1( verifyCurrentBundle, } = options; let verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle'] | undefined; - let currentEnvelope: SignedAgentProfileHeadEnvelopeV1 | undefined; - const { authoritySummary: verifiedAuthoritySummary } = + const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { nowMs, resolve: async (reference) => { @@ -245,9 +243,11 @@ async function verifyActiveProfileClosureForRowV1( ); if (reference.objectKind === 'agent-profile-head' && reference.digest === row.headDigest) { - const parsed = parseCanonicalSignedAgentProfileHeadEnvelopeV1(owned.canonicalBytes); - assertRowBindsHead(networkId, row, parsed); - currentEnvelope = parsed; + assertRowBindsHead( + networkId, + row, + parseCanonicalSignedAgentProfileHeadEnvelopeV1(owned.canonicalBytes), + ); } return Object.freeze({ objectKind: owned.objectKind, @@ -270,24 +270,26 @@ async function verifyActiveProfileClosureForRowV1( ); signal.throwIfAborted(); const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); - verifiedBundle = Object.freeze({ - ...snapshotVerifiedBundle(result), - canonicalProjectionBytes: Uint8Array.from(decoded.projectionBytes), - }); + verifiedBundle = snapshotVerifiedBundle(result, decoded.projectionBytes); return true; }, }); - const envelope = currentEnvelope; - if (envelope === undefined) { + const currentHeadArtifact = closure.objects.find((artifact) => + artifact.objectKind === 'agent-profile-head' && artifact.digest === row.headDigest, + ); + if (currentHeadArtifact === undefined) throw new Error('verification closure did not retain its current agent-profile head'); - } + const envelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( + currentHeadArtifact.canonicalBytes, + ); + assertRowBindsHead(networkId, row, envelope); if (envelope.object.state !== 'active' || verifiedBundle === undefined) { throw new Error('active profile receiver resolved a non-active verification closure'); } return Object.freeze({ envelope: envelope as VerifiedActiveProfileClosureV1['envelope'], verifiedBundle, - verifiedAuthoritySummary, + verifiedAuthoritySummary: closure.authoritySummary, }); } @@ -340,17 +342,30 @@ function snapshotExpectedArtifactV1( }); } -function snapshotVerifiedBundle(value: AgentProfileReceiverVerifiedBundleV1): AgentProfileReceiverVerifiedBundleV1 { - if (value === null || typeof value !== 'object' || !Array.isArray(value.projectionQuads)) { +function snapshotVerifiedBundle( + value: AgentProfileReceiverVerifiedBundleV1, + expectedProjectionBytes: Uint8Array, +): AgentProfileReceiverVerifiedBundleV1 { + if (value === null || typeof value !== 'object' + || !(value.canonicalProjectionBytes instanceof Uint8Array) + || !Array.isArray(value.projectionQuads)) { throw new Error('bundle verifier returned an invalid projection'); } + const suppliedProjectionBytes = value.canonicalProjectionBytes; + if (suppliedProjectionBytes.byteLength !== expectedProjectionBytes.byteLength + || suppliedProjectionBytes.some((byte, index) => byte !== expectedProjectionBytes[index])) { + throw new Error('bundle verifier projection does not bind the supplied bundle'); + } const projectionQuads = value.projectionQuads.map((quad) => Object.freeze({ subject: quad.subject, predicate: quad.predicate, object: quad.object, graph: quad.graph, })); - return Object.freeze({ projectionQuads: Object.freeze(projectionQuads) }); + return Object.freeze({ + canonicalProjectionBytes: Uint8Array.from(expectedProjectionBytes), + projectionQuads: Object.freeze(projectionQuads), + }); } function receiverNowMs(value: number): number { diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index b2cb2695a1..ac7411c5db 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it, vi } from 'vitest'; import { decodeOpaqueKaBundleV1 } from '@origintrail-official/dkg-core'; import { + canonicalizeOwnedSubjectTableObjectV1, computeSystemRecordStableKeyHashV1, + deriveAgentProfileOwnedSubjectV1, SYSTEM_RECORD_OBJECT_CAPS_V1, + type OwnedSubjectTableObjectV1, type SystemRecordInventoryRowV1, } from '@origintrail-official/dkg-core/system-record-v1'; @@ -51,6 +54,7 @@ async function publishedFixture() { function verifyFixtureBundle(_head: unknown, bundleBytes: Uint8Array) { const { projectionBytes } = decodeOpaqueKaBundleV1(bundleBytes); return Object.freeze({ + canonicalProjectionBytes: Uint8Array.from(projectionBytes), projectionQuads: Object.freeze(parseNQuads(new TextDecoder().decode(projectionBytes))), }); } @@ -81,7 +85,15 @@ describe('agent-profile system-record active receiver', () => { expect([...candidate.projectionQuads].sort(compareQuad)) .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); expect(candidate.ownedSubjectTable).toContain(fixture.prepared.rootEntity); - expect(candidate.canonicalProjectionBytes.byteLength).toBeGreaterThan(0); + const bundleArtifact = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, signal); + if (bundleArtifact === null) throw new Error('fixture bundle was not retained'); + expect(candidate.canonicalProjectionBytes).toEqual( + decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, + ); expect(candidate).not.toHaveProperty('signal'); expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); }); @@ -107,6 +119,59 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('fails closed when the owned-subject table bytes do not bind the verified head', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + const alteredTable = Object.freeze([ + fixture.envelope.object.rootSubject, + deriveAgentProfileOwnedSubjectV1(fixture.envelope.object.rootSubject, 'capability', 1), + ].sort()) as OwnedSubjectTableObjectV1; + const alteredBytes = canonicalizeOwnedSubjectTableObjectV1( + fixture.envelope.object.rootSubject, + alteredTable, + ); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: Object.freeze({ + resolve: async (lookup, signal) => { + const artifact = await fixture.store.resolve(lookup, signal); + if (artifact === null || lookup.type !== 'object' + || lookup.objectKind !== 'owned-subject-table') return artifact; + return Object.freeze({ ...artifact, canonicalBytes: alteredBytes }); + }, + }), + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: verifyFixtureBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/does not bind the verified head/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + + it('fails closed when verified projection bytes do not bind the supplied bundle', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: (head, bundleBytes) => { + const verified = verifyFixtureBundle(head, bundleBytes); + return Object.freeze({ + ...verified, + canonicalProjectionBytes: Uint8Array.from([0]), + }); + }, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/projection does not bind the supplied bundle/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('returns a committed apply outcome when cancellation arrives at the point of no return', async () => { const fixture = await publishedFixture(); const controller = new AbortController(); From 067a8739dc310b093e69681ea2a1061821492646 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 17:46:21 +0200 Subject: [PATCH 07/18] test(agent): pin active receiver handoff --- .../agent/src/system-records/receiver-v1.ts | 23 +++- .../test/system-record-receiver-v1.test.ts | 114 ++++++++++++++---- 2 files changed, 108 insertions(+), 29 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 2c608efd04..7360fe78a8 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -45,10 +45,14 @@ export interface AgentProfileReceiverVerifiedBundleV1 { readonly projectionQuads: readonly Readonly[]; } +export type SignedAgentProfileActiveHeadEnvelopeV1 = SignedAgentProfileHeadEnvelopeV1 & { + readonly object: AgentProfileActiveHeadObjectV1; +}; + /** Verified active-profile facts handed to the lifecycle-owned materializer bridge. */ export interface AgentProfileReceiverCandidateV1 { readonly head: AgentProfileActiveHeadObjectV1; - readonly envelope: SignedAgentProfileHeadEnvelopeV1; + readonly envelope: SignedAgentProfileActiveHeadEnvelopeV1; readonly canonicalProjectionBytes: Uint8Array; readonly projectionQuads: readonly Readonly[]; readonly ownedSubjectTable: OwnedSubjectTableObjectV1; @@ -188,9 +192,7 @@ export function createAgentProfileReceiverV1( } interface VerifiedActiveProfileClosureV1 { - readonly envelope: SignedAgentProfileHeadEnvelopeV1 & { - readonly object: AgentProfileActiveHeadObjectV1; - }; + readonly envelope: SignedAgentProfileActiveHeadEnvelopeV1; readonly verifiedBundle: Readonly<{ readonly projectionQuads: readonly Readonly[]; readonly canonicalProjectionBytes: Uint8Array; @@ -283,16 +285,25 @@ async function verifyActiveProfileClosureForRowV1( currentHeadArtifact.canonicalBytes, ); assertRowBindsHead(networkId, row, envelope); - if (envelope.object.state !== 'active' || verifiedBundle === undefined) { + assertActiveHeadEnvelopeV1(envelope); + if (verifiedBundle === undefined) { throw new Error('active profile receiver resolved a non-active verification closure'); } return Object.freeze({ - envelope: envelope as VerifiedActiveProfileClosureV1['envelope'], + envelope, verifiedBundle, verifiedAuthoritySummary: closure.authoritySummary, }); } +function assertActiveHeadEnvelopeV1( + envelope: SignedAgentProfileHeadEnvelopeV1, +): asserts envelope is SignedAgentProfileActiveHeadEnvelopeV1 { + if (envelope.object.state !== 'active') { + throw new Error('active profile receiver resolved a non-active verification closure'); + } +} + function canonicalInventoryRow( networkId: NetworkIdV1, row: SystemRecordInventoryRowV1, diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index ac7411c5db..80c636dfac 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -12,8 +12,10 @@ import { } from '@origintrail-official/dkg-core/system-record-v1'; import { parseNQuads } from '../src/dkg-agent-utils.js'; +import { prepareAgentProfileV1 } from '../src/profile.js'; import { createAgentProfileReceiverV1, + type AgentProfileReceiverCandidateV1, } from '../src/system-records/receiver-v1.js'; import { createFixtureAgentProfileProducerV1, @@ -22,10 +24,32 @@ import { produce, producerFixture, PRODUCER_FIXTURE_NOW_MS, + publicationFor, } from './support/agent-profile-producer-v1-fixture.js'; -async function publishedFixture() { +async function publishedFixture(withDerivedSubjects = false) { const fixture = await producerFixture(); + const prepared = withDerivedSubjects + ? prepareAgentProfileV1({ + peerId: fixture.peerSigner.peerId, + publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), + agentAddress: fixture.evmSigner.address, + name: 'Receiver multi-subject fixture', + nodeRole: 'edge', + lastSeen: '2026-08-07T12:00:00.000Z', + skills: [{ + skillType: 'GraphQuery', + pricePerCall: 1, + currency: 'TRAC', + successRate: 0.99, + pricingModel: 'PerInvocation', + }], + contextGraphsServed: ['receiver-test-graph'], + }) + : fixture.prepared; + const publication = withDerivedSubjects + ? await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z') + : fixture.publication; const producer = createFixtureAgentProfileProducerV1({ networkId: NETWORK, publicationDeployment: DEPLOYMENT, @@ -35,7 +59,7 @@ async function publishedFixture() { fence: () => undefined, install: () => undefined, }); - await produce(producer, fixture.prepared, fixture.publication); + await produce(producer, prepared, publication); const envelope = fixture.store.snapshot().currentHead; if (envelope === null) throw new Error('fixture producer did not publish a head'); const head = envelope.object; @@ -48,10 +72,10 @@ async function publishedFixture() { tombstone: false, quarantined: false, }); - return { ...fixture, envelope, row }; + return { ...fixture, prepared, publication, envelope, row }; } -function verifyFixtureBundle(_head: unknown, bundleBytes: Uint8Array) { +function verifiedFixtureBundle(bundleBytes: Uint8Array) { const { projectionBytes } = decodeOpaqueKaBundleV1(bundleBytes); return Object.freeze({ canonicalProjectionBytes: Uint8Array.from(projectionBytes), @@ -62,7 +86,20 @@ function verifyFixtureBundle(_head: unknown, bundleBytes: Uint8Array) { describe('agent-profile system-record active receiver', () => { it('verifies the exact closure and submits one immutable active candidate', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(async () => ({ + const signal = new AbortController().signal; + const bundleArtifact = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, signal); + if (bundleArtifact === null) throw new Error('fixture bundle was not retained'); + const verifyCurrentBundle = vi.fn((head, bundleBytes: Uint8Array, receivedSignal) => { + expect(head).toEqual(fixture.envelope.object); + expect(bundleBytes).toEqual(bundleArtifact.canonicalBytes); + expect(receivedSignal).toBe(signal); + return verifiedFixtureBundle(bundleBytes); + }); + const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ outcome: 'applied' as const, stateRevision: '1', appliedStateDigest: `0x${'a'.repeat(64)}`, @@ -71,13 +108,13 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle, consumeCandidate, }); - const signal = new AbortController().signal; await expect(receiver.receiveActive(fixture.row, signal)) .resolves.toMatchObject({ outcome: 'applied' }); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); expect(consumeCandidate).toHaveBeenCalledTimes(1); const candidate = consumeCandidate.mock.calls[0]![0]; expect(candidate.head).toEqual(fixture.envelope.object); @@ -85,12 +122,6 @@ describe('agent-profile system-record active receiver', () => { expect([...candidate.projectionQuads].sort(compareQuad)) .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); expect(candidate.ownedSubjectTable).toContain(fixture.prepared.rootEntity); - const bundleArtifact = await fixture.store.resolve({ - type: 'object', - objectKind: 'profile-bundle', - objectDigest: fixture.envelope.object.bundleDigest, - }, signal); - if (bundleArtifact === null) throw new Error('fixture bundle was not retained'); expect(candidate.canonicalProjectionBytes).toEqual( decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, ); @@ -98,6 +129,35 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); }); + it('hands every derived owned subject to the materializer candidate', async () => { + const fixture = await publishedFixture(true); + const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ + outcome: 'applied' as const, + stateRevision: '1', + appliedStateDigest: `0x${'a'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + consumeCandidate, + }); + + await expect(receiver.receiveActive( + fixture.row, + new AbortController().signal, + )).resolves.toMatchObject({ outcome: 'applied' }); + const candidate = consumeCandidate.mock.calls[0]![0]; + const expectedOwnedSubjects = [...new Set( + fixture.prepared.projectionQuads.map(({ subject }) => subject), + )].sort(compareUtf8); + expect(expectedOwnedSubjects.length).toBeGreaterThan(1); + expect(candidate.ownedSubjectTable).toEqual(expectedOwnedSubjects); + expect(candidate.head.ownedSubjectCount).toBe(String(expectedOwnedSubjects.length)); + expect(candidate.envelope.object.state).toBe('active'); + }); + it('fails closed when the exact owned-subject table is unavailable', async () => { const fixture = await publishedFixture(); const consumeCandidate = vi.fn(); @@ -110,7 +170,7 @@ describe('agent-profile system-record active receiver', () => { : fixture.store.resolve(lookup, signal), }), nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), consumeCandidate, }); @@ -141,7 +201,7 @@ describe('agent-profile system-record active receiver', () => { }, }), nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), consumeCandidate, }); @@ -157,8 +217,8 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (head, bundleBytes) => { - const verified = verifyFixtureBundle(head, bundleBytes); + verifyCurrentBundle: (_head, bundleBytes) => { + const verified = verifiedFixtureBundle(bundleBytes); return Object.freeze({ ...verified, canonicalProjectionBytes: Uint8Array.from([0]), @@ -179,7 +239,7 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), consumeCandidate: async () => { controller.abort(new Error('late stop')); return { @@ -247,7 +307,7 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), consumeCandidate: vi.fn(), }); @@ -262,7 +322,9 @@ describe('agent-profile system-record active receiver', () => { const fixture = await publishedFixture(); const consumeCandidate = vi.fn(); const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); - const verifyCurrentBundle = vi.fn(verifyFixtureBundle); + const verifyCurrentBundle = vi.fn( + (_head, bundleBytes: Uint8Array) => verifiedFixtureBundle(bundleBytes), + ); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { resolve }, @@ -288,7 +350,7 @@ describe('agent-profile system-record active receiver', () => { artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyAuthorityEnvelope: () => false, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), consumeCandidate, }); @@ -323,7 +385,7 @@ describe('agent-profile system-record active receiver', () => { }, }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: verifyFixtureBundle, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), consumeCandidate, }); @@ -336,7 +398,9 @@ describe('agent-profile system-record active receiver', () => { it('captures lifecycle dependencies once instead of rereading mutable options', async () => { const fixture = await publishedFixture(); - const verifyCurrentBundle = vi.fn(verifyFixtureBundle); + const verifyCurrentBundle = vi.fn( + (_head, bundleBytes: Uint8Array) => verifiedFixtureBundle(bundleBytes), + ); const consumeCandidate = vi.fn(async () => ({ outcome: 'applied' as const, stateRevision: '3', @@ -381,3 +445,7 @@ function compareQuad( || left.object.localeCompare(right.object) || left.graph.localeCompare(right.graph); } + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} From a4b519292a2dfd02448ad82bb7e3b481706da570 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 19:06:23 +0200 Subject: [PATCH 08/18] chore: retrigger review for active receiver From 7c79c98533a96d14c4667f75687a00d591767e3a Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 22:49:35 +0200 Subject: [PATCH 09/18] refactor(agent): assemble verified active candidate facts --- .../agent/src/system-records/receiver-v1.ts | 293 +++++++++++------- .../test/system-record-receiver-v1.test.ts | 57 ++++ 2 files changed, 245 insertions(+), 105 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 7360fe78a8..757f789ce1 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -27,6 +27,7 @@ import { type SignedAgentProfileHeadEnvelopeV1, type SystemRecordInventoryRowV1, type SystemRecordObjectKindV1, + type SystemRecordVerificationClosureV1, } from '@origintrail-official/dkg-core/system-record-v1'; import type { Quad, @@ -134,11 +135,7 @@ export function createAgentProfileReceiverV1( throw new Error('active profile receiver requires an ordinary active inventory row'); } - const { - envelope, - verifiedBundle, - verifiedAuthoritySummary, - } = await verifyActiveProfileClosureForRowV1({ + const candidate = await buildVerifiedActiveCandidateFactsV1({ networkId, row, signal, @@ -148,41 +145,7 @@ export function createAgentProfileReceiverV1( verifyCurrentBundle, }); signal.throwIfAborted(); - - const head = envelope.object; - const resolvedSubjectTableArtifact = await resolveArtifact({ - type: 'object', - objectKind: 'owned-subject-table', - objectDigest: head.ownedSubjectTableDigest, - }, signal); - signal.throwIfAborted(); - if (resolvedSubjectTableArtifact === null) { - throw new Error('active profile receiver is missing its exact owned-subject table'); - } - const subjectTableArtifact = snapshotExpectedArtifactV1( - resolvedSubjectTableArtifact, - 'owned-subject-table', - head.ownedSubjectTableDigest, - 'owned-subject table', - ); - const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( - head.rootSubject, - subjectTableArtifact.canonicalBytes, - ); - if (computeOwnedSubjectTableDigestV1(head.rootSubject, ownedSubjectTable) - !== head.ownedSubjectTableDigest - || BigInt(ownedSubjectTable.length) !== BigInt(head.ownedSubjectCount)) { - throw new Error('active profile owned-subject table does not bind the verified head'); - } - - const outcome = await consumeCandidate(Object.freeze({ - head, - envelope, - canonicalProjectionBytes: verifiedBundle.canonicalProjectionBytes, - projectionQuads: verifiedBundle.projectionQuads, - ownedSubjectTable, - verifiedAuthoritySummary, - }), signal); + const outcome = await consumeCandidate(candidate, signal); // Atomic apply is the point of no return. A cancellation that arrives // after the storage closure returns must not hide a committed outcome and // make the caller retry it as if nothing happened. @@ -192,15 +155,14 @@ export function createAgentProfileReceiverV1( } interface VerifiedActiveProfileClosureV1 { - readonly envelope: SignedAgentProfileActiveHeadEnvelopeV1; + readonly closure: SystemRecordVerificationClosureV1; readonly verifiedBundle: Readonly<{ readonly projectionQuads: readonly Readonly[]; readonly canonicalProjectionBytes: Uint8Array; }>; - readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; } -interface VerifyActiveProfileClosureOptionsV1 { +interface BuildVerifiedActiveCandidateFactsOptionsV1 { readonly networkId: NetworkIdV1; readonly row: SystemRecordInventoryRowV1; readonly signal: AbortSignal; @@ -212,9 +174,9 @@ interface VerifyActiveProfileClosureOptionsV1 { readonly verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle']; } -async function verifyActiveProfileClosureForRowV1( - options: VerifyActiveProfileClosureOptionsV1, -): Promise { +async function buildVerifiedActiveCandidateFactsV1( + options: BuildVerifiedActiveCandidateFactsOptionsV1, +): Promise { const { networkId, row, @@ -224,78 +186,199 @@ async function verifyActiveProfileClosureForRowV1( verifyAuthorityEnvelope, verifyCurrentBundle, } = options; - let verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle'] | undefined; - const closure = - await buildAgentProfileVerificationClosureV1(row.headDigest, { - nowMs, - resolve: async (reference) => { - signal.throwIfAborted(); - const artifact = await resolveArtifact({ - type: 'object', - objectKind: reference.objectKind, - objectDigest: reference.digest, - }, signal); - signal.throwIfAborted(); - if (artifact === null) return undefined; - const owned = snapshotExpectedArtifactV1( - artifact, - reference.objectKind, - reference.digest, - 'closure artifact', - ); - if (reference.objectKind === 'agent-profile-head' - && reference.digest === row.headDigest) { - assertRowBindsHead( - networkId, - row, - parseCanonicalSignedAgentProfileHeadEnvelopeV1(owned.canonicalBytes), - ); - } - return Object.freeze({ - objectKind: owned.objectKind, - digest: owned.objectDigest, - canonicalBytes: owned.canonicalBytes, - }); - }, - verifyAuthorityEnvelope: async (envelope) => { - signal.throwIfAborted(); - const verified = await verifyAuthorityEnvelope(envelope, signal); - signal.throwIfAborted(); - return verified === true; - }, - verifyCurrentBundle: async (head, canonicalBundleBytes) => { - signal.throwIfAborted(); - const result = await verifyCurrentBundle( - head, - Uint8Array.from(canonicalBundleBytes), - signal, - ); - signal.throwIfAborted(); - const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); - verifiedBundle = snapshotVerifiedBundle(result, decoded.projectionBytes); - return true; - }, - }); - const currentHeadArtifact = closure.objects.find((artifact) => - artifact.objectKind === 'agent-profile-head' && artifact.digest === row.headDigest, + signal.throwIfAborted(); + const resolvedCurrentHeadArtifact = await resolveArtifact({ + type: 'object', + objectKind: 'agent-profile-head', + objectDigest: row.headDigest, + }, signal); + signal.throwIfAborted(); + if (resolvedCurrentHeadArtifact === null) { + throw new Error('verification closure is missing its current agent-profile head'); + } + const currentHeadArtifact = snapshotExpectedArtifactV1( + resolvedCurrentHeadArtifact, + 'agent-profile-head', + row.headDigest, + 'closure artifact', ); - if (currentHeadArtifact === undefined) - throw new Error('verification closure did not retain its current agent-profile head'); const envelope = parseCanonicalSignedAgentProfileHeadEnvelopeV1( currentHeadArtifact.canonicalBytes, ); assertRowBindsHead(networkId, row, envelope); assertActiveHeadEnvelopeV1(envelope); - if (verifiedBundle === undefined) { - throw new Error('active profile receiver resolved a non-active verification closure'); + + const { closure, verifiedBundle } = await verifyActiveProfileClosureForRowV1({ + row, + signal, + nowMs, + currentHeadArtifact, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + }); + signal.throwIfAborted(); + if (!closure.objects.some((artifact) => + artifact.objectKind === 'agent-profile-head' && artifact.digest === row.headDigest)) { + throw new Error('verification closure did not retain its current agent-profile head'); + } + + const head = envelope.object; + const resolvedSubjectTableArtifact = await resolveArtifact({ + type: 'object', + objectKind: 'owned-subject-table', + objectDigest: head.ownedSubjectTableDigest, + }, signal); + signal.throwIfAborted(); + if (resolvedSubjectTableArtifact === null) { + throw new Error('active profile receiver is missing its exact owned-subject table'); + } + const subjectTableArtifact = snapshotExpectedArtifactV1( + resolvedSubjectTableArtifact, + 'owned-subject-table', + head.ownedSubjectTableDigest, + 'owned-subject table', + ); + const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( + head.rootSubject, + subjectTableArtifact.canonicalBytes, + ); + if (computeOwnedSubjectTableDigestV1(head.rootSubject, ownedSubjectTable) + !== head.ownedSubjectTableDigest + || BigInt(ownedSubjectTable.length) !== BigInt(head.ownedSubjectCount)) { + throw new Error('active profile owned-subject table does not bind the verified head'); } + return Object.freeze({ + head, envelope, - verifiedBundle, + canonicalProjectionBytes: verifiedBundle.canonicalProjectionBytes, + projectionQuads: verifiedBundle.projectionQuads, + ownedSubjectTable, verifiedAuthoritySummary: closure.authoritySummary, }); } +interface VerifyActiveProfileClosureOptionsV1 + extends Omit { + readonly currentHeadArtifact: SystemRecordArtifactV1; +} + +async function verifyActiveProfileClosureForRowV1( + options: VerifyActiveProfileClosureOptionsV1, +): Promise { + const { + row, + signal, + nowMs, + currentHeadArtifact, + resolveArtifact, + verifyAuthorityEnvelope, + verifyCurrentBundle, + } = options; + const bundleVerification = createExactOnceBundleVerificationAdapterV1( + verifyCurrentBundle, + signal, + ); + const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { + nowMs, + resolve: async (reference) => { + signal.throwIfAborted(); + const owned = reference.objectKind === 'agent-profile-head' + && reference.digest === row.headDigest + ? currentHeadArtifact + : await resolveClosureArtifactV1(reference, resolveArtifact, signal); + signal.throwIfAborted(); + if (owned === undefined) return undefined; + return Object.freeze({ + objectKind: owned.objectKind, + digest: owned.objectDigest, + canonicalBytes: owned.canonicalBytes, + }); + }, + verifyAuthorityEnvelope: async (envelope) => { + signal.throwIfAborted(); + const verified = await verifyAuthorityEnvelope(envelope, signal); + signal.throwIfAborted(); + return verified === true; + }, + verifyCurrentBundle: bundleVerification.verify, + }); + return bundleVerification.complete(closure); +} + +interface ExactOnceBundleVerificationAdapterV1 { + readonly verify: ( + head: AgentProfileActiveHeadObjectV1, + canonicalBundleBytes: Uint8Array, + ) => Promise; + readonly complete: ( + closure: SystemRecordVerificationClosureV1, + ) => VerifiedActiveProfileClosureV1; +} + +function createExactOnceBundleVerificationAdapterV1( + verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle'], + signal: AbortSignal, +): ExactOnceBundleVerificationAdapterV1 { + let state: + | Readonly<{ phase: 'waiting' | 'verifying' }> + | Readonly<{ + phase: 'verified'; + verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle']; + }> = Object.freeze({ phase: 'waiting' }); + + return Object.freeze({ + verify: async ( + head: AgentProfileActiveHeadObjectV1, + canonicalBundleBytes: Uint8Array, + ) => { + if (state.phase !== 'waiting') { + throw new Error('active profile bundle verification must run exactly once'); + } + state = Object.freeze({ phase: 'verifying' }); + signal.throwIfAborted(); + const result = await verifyCurrentBundle( + head, + Uint8Array.from(canonicalBundleBytes), + signal, + ); + signal.throwIfAborted(); + const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); + state = Object.freeze({ + phase: 'verified', + verifiedBundle: snapshotVerifiedBundle(result, decoded.projectionBytes), + }); + return true; + }, + complete: (closure: SystemRecordVerificationClosureV1) => { + if (state.phase !== 'verified') { + throw new Error('active profile receiver resolved a non-active verification closure'); + } + return Object.freeze({ closure, verifiedBundle: state.verifiedBundle }); + }, + }); +} + +async function resolveClosureArtifactV1( + reference: Readonly<{ objectKind: SystemRecordObjectKindV1; digest: Digest32V1 }>, + resolveArtifact: SystemRecordArtifactRepositoryV1['resolve'], + signal: AbortSignal, +): Promise { + const artifact = await resolveArtifact({ + type: 'object', + objectKind: reference.objectKind, + objectDigest: reference.digest, + }, signal); + if (artifact === null) return undefined; + return snapshotExpectedArtifactV1( + artifact, + reference.objectKind, + reference.digest, + 'closure artifact', + ); +} + function assertActiveHeadEnvelopeV1( envelope: SignedAgentProfileHeadEnvelopeV1, ): asserts envelope is SignedAgentProfileActiveHeadEnvelopeV1 { diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 80c636dfac..8d7e378c8f 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -6,7 +6,9 @@ import { canonicalizeOwnedSubjectTableObjectV1, computeSystemRecordStableKeyHashV1, deriveAgentProfileOwnedSubjectV1, + EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, SYSTEM_RECORD_OBJECT_CAPS_V1, + type AgentProfileHeadObjectV1, type OwnedSubjectTableObjectV1, type SystemRecordInventoryRowV1, } from '@origintrail-official/dkg-core/system-record-v1'; @@ -20,11 +22,13 @@ import { import { createFixtureAgentProfileProducerV1, DEPLOYMENT, + envelopeArtifact, NETWORK, produce, producerFixture, PRODUCER_FIXTURE_NOW_MS, publicationFor, + signHeadEnvelope, } from './support/agent-profile-producer-v1-fixture.js'; async function publishedFixture(withDerivedSubjects = false) { @@ -129,6 +133,59 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); }); + it('does not invoke active bundle verification for a non-active current head', async () => { + const fixture = await publishedFixture(); + const active = fixture.envelope.object; + const tombstone = { + objectType: 'agent-profile-head', + kind: 'agents', + state: 'tombstone', + networkId: active.networkId, + peerId: active.peerId, + peerPublicKey: active.peerPublicKey, + authoritySequence: active.authoritySequence, + version: '1', + previousHeadDigest: fixture.envelope.objectDigest, + evmIssuer: active.evmIssuer, + rootSubject: active.rootSubject, + projectionSchemaDigest: active.projectionSchemaDigest, + issuedAt: '2026-08-07T12:10:00Z', + ownedSubjectTableDigest: EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + ownedSubjectCount: '0', + projectionBytes: '0', + projectionQuads: '0', + } as AgentProfileHeadObjectV1; + const tombstoneEnvelope = await signHeadEnvelope( + tombstone, + fixture.peerSigner, + fixture.evmSigner, + ); + const tombstoneArtifact = envelopeArtifact('agent-profile-head', tombstoneEnvelope); + const resolve = vi.fn(async (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'agent-profile-head' + && lookup.objectDigest === tombstoneEnvelope.objectDigest + ? tombstoneArtifact + : fixture.store.resolve(lookup, signal)); + const verifyCurrentBundle = vi.fn(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(Object.freeze({ + ...fixture.row, + version: tombstone.version, + headDigest: tombstoneEnvelope.objectDigest, + }), new AbortController().signal)).rejects.toThrow(/inventory row does not bind/); + expect(resolve).toHaveBeenCalledTimes(1); + expect(verifyCurrentBundle).not.toHaveBeenCalled(); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('hands every derived owned subject to the materializer candidate', async () => { const fixture = await publishedFixture(true); const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ From ec7677e276d1bf0555f06fc33fcbb118f6dc51de Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 23:05:13 +0200 Subject: [PATCH 10/18] fix(agent): enforce active receiver freshness --- .../agent/src/system-records/receiver-v1.ts | 109 ++++++++---------- .../test/system-record-receiver-v1.test.ts | 75 ++++++++++++ 2 files changed, 124 insertions(+), 60 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 757f789ce1..ae0c29e2dd 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -207,11 +207,15 @@ async function buildVerifiedActiveCandidateFactsV1( ); assertRowBindsHead(networkId, row, envelope); assertActiveHeadEnvelopeV1(envelope); + if (Date.parse(envelope.object.validUntil) <= nowMs) { + throw new Error('active profile receiver resolved an expired agent-profile head'); + } const { closure, verifiedBundle } = await verifyActiveProfileClosureForRowV1({ row, signal, nowMs, + envelope, currentHeadArtifact, resolveArtifact, verifyAuthorityEnvelope, @@ -261,6 +265,7 @@ async function buildVerifiedActiveCandidateFactsV1( interface VerifyActiveProfileClosureOptionsV1 extends Omit { + readonly envelope: SignedAgentProfileActiveHeadEnvelopeV1; readonly currentHeadArtifact: SystemRecordArtifactV1; } @@ -271,23 +276,42 @@ async function verifyActiveProfileClosureForRowV1( row, signal, nowMs, + envelope, currentHeadArtifact, resolveArtifact, verifyAuthorityEnvelope, verifyCurrentBundle, } = options; - const bundleVerification = createExactOnceBundleVerificationAdapterV1( - verifyCurrentBundle, - signal, + signal.throwIfAborted(); + const resolvedCurrentBundleArtifact = await resolveArtifact({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: envelope.object.bundleDigest, + }, signal); + signal.throwIfAborted(); + if (resolvedCurrentBundleArtifact === null) { + throw new Error(`verification closure is missing ${envelope.object.bundleDigest}`); + } + const currentBundleArtifact = snapshotExpectedArtifactV1( + resolvedCurrentBundleArtifact, + 'profile-bundle', + envelope.object.bundleDigest, + 'closure artifact', ); const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { nowMs, resolve: async (reference) => { signal.throwIfAborted(); - const owned = reference.objectKind === 'agent-profile-head' - && reference.digest === row.headDigest - ? currentHeadArtifact - : await resolveClosureArtifactV1(reference, resolveArtifact, signal); + let owned: SystemRecordArtifactV1 | undefined; + if (reference.objectKind === 'agent-profile-head' + && reference.digest === row.headDigest) { + owned = currentHeadArtifact; + } else if (reference.objectKind === 'profile-bundle' + && reference.digest === envelope.object.bundleDigest) { + owned = currentBundleArtifact; + } else { + owned = await resolveClosureArtifactV1(reference, resolveArtifact, signal); + } signal.throwIfAborted(); if (owned === undefined) return undefined; return Object.freeze({ @@ -302,61 +326,21 @@ async function verifyActiveProfileClosureForRowV1( signal.throwIfAborted(); return verified === true; }, - verifyCurrentBundle: bundleVerification.verify, + verifyCurrentBundle: (head, canonicalBundleBytes) => + head.bundleDigest === envelope.object.bundleDigest + && systemRecordBytesEqualV1(canonicalBundleBytes, currentBundleArtifact.canonicalBytes), }); - return bundleVerification.complete(closure); -} - -interface ExactOnceBundleVerificationAdapterV1 { - readonly verify: ( - head: AgentProfileActiveHeadObjectV1, - canonicalBundleBytes: Uint8Array, - ) => Promise; - readonly complete: ( - closure: SystemRecordVerificationClosureV1, - ) => VerifiedActiveProfileClosureV1; -} - -function createExactOnceBundleVerificationAdapterV1( - verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle'], - signal: AbortSignal, -): ExactOnceBundleVerificationAdapterV1 { - let state: - | Readonly<{ phase: 'waiting' | 'verifying' }> - | Readonly<{ - phase: 'verified'; - verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle']; - }> = Object.freeze({ phase: 'waiting' }); - + signal.throwIfAborted(); + const result = await verifyCurrentBundle( + envelope.object, + Uint8Array.from(currentBundleArtifact.canonicalBytes), + signal, + ); + signal.throwIfAborted(); + const decoded = decodeOpaqueKaBundleV1(currentBundleArtifact.canonicalBytes); return Object.freeze({ - verify: async ( - head: AgentProfileActiveHeadObjectV1, - canonicalBundleBytes: Uint8Array, - ) => { - if (state.phase !== 'waiting') { - throw new Error('active profile bundle verification must run exactly once'); - } - state = Object.freeze({ phase: 'verifying' }); - signal.throwIfAborted(); - const result = await verifyCurrentBundle( - head, - Uint8Array.from(canonicalBundleBytes), - signal, - ); - signal.throwIfAborted(); - const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); - state = Object.freeze({ - phase: 'verified', - verifiedBundle: snapshotVerifiedBundle(result, decoded.projectionBytes), - }); - return true; - }, - complete: (closure: SystemRecordVerificationClosureV1) => { - if (state.phase !== 'verified') { - throw new Error('active profile receiver resolved a non-active verification closure'); - } - return Object.freeze({ closure, verifiedBundle: state.verifiedBundle }); - }, + closure, + verifiedBundle: snapshotVerifiedBundle(result, decoded.projectionBytes), }); } @@ -462,6 +446,11 @@ function snapshotVerifiedBundle( }); } +function systemRecordBytesEqualV1(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength + && left.every((byte, index) => byte === right[index]); +} + function receiverNowMs(value: number): number { if (!Number.isSafeInteger(value) || value < 0) { throw new Error('agent-profile receiver clock returned an invalid value'); diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 8d7e378c8f..e9bafd34c4 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -186,6 +186,42 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('rejects an expired active head before bundle verification or materialization', async () => { + const fixture = await publishedFixture(); + const expiredHead = Object.freeze({ + ...fixture.envelope.object, + validUntil: '2026-08-07T12:20:00Z', + }) as AgentProfileHeadObjectV1; + const expiredEnvelope = await signHeadEnvelope( + expiredHead, + fixture.peerSigner, + fixture.evmSigner, + ); + const expiredArtifact = envelopeArtifact('agent-profile-head', expiredEnvelope); + const resolve = vi.fn(async (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'agent-profile-head' + && lookup.objectDigest === expiredEnvelope.objectDigest + ? expiredArtifact + : fixture.store.resolve(lookup, signal)); + const verifyCurrentBundle = vi.fn(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(Object.freeze({ + ...fixture.row, + headDigest: expiredEnvelope.objectDigest, + }), new AbortController().signal)).rejects.toThrow(/expired agent-profile head/); + expect(resolve).toHaveBeenCalledTimes(1); + expect(verifyCurrentBundle).not.toHaveBeenCalled(); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('hands every derived owned subject to the materializer candidate', async () => { const fixture = await publishedFixture(true); const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ @@ -289,6 +325,45 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('isolates signed bundle bytes from mutations by the injected verifier', async () => { + const fixture = await publishedFixture(); + const signal = new AbortController().signal; + const bundleArtifact = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, signal); + if (bundleArtifact === null) throw new Error('fixture bundle was not retained'); + const expectedProjectionBytes = Uint8Array.from( + decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, + ); + const verifyCurrentBundle = vi.fn((_head, bundleBytes: Uint8Array) => { + const verified = verifiedFixtureBundle(Uint8Array.from(bundleBytes)); + bundleBytes.fill(0); + return verified; + }); + const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ + outcome: 'applied' as const, + stateRevision: '4', + appliedStateDigest: `0x${'f'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + const candidate = consumeCandidate.mock.calls[0]![0]; + expect(candidate.canonicalProjectionBytes).toEqual(expectedProjectionBytes); + expect([...candidate.projectionQuads].sort(compareQuad)) + .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); + }); + it('returns a committed apply outcome when cancellation arrives at the point of no return', async () => { const fixture = await publishedFixture(); const controller = new AbortController(); From 0a7f28deee28caeb27958062886aa44314ff56dc Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 23:21:57 +0200 Subject: [PATCH 11/18] fix(agent): close receiver verification races --- .../agent/src/system-records/receiver-v1.ts | 109 +++++----- .../test/system-record-receiver-v1.test.ts | 189 ++++++++++++++++++ 2 files changed, 250 insertions(+), 48 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index ae0c29e2dd..0a03b62cde 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -135,16 +135,21 @@ export function createAgentProfileReceiverV1( throw new Error('active profile receiver requires an ordinary active inventory row'); } + const verificationNowMs = receiverNowMs(nowMs?.() ?? Date.now()); const candidate = await buildVerifiedActiveCandidateFactsV1({ networkId, row, signal, - nowMs: receiverNowMs(nowMs?.() ?? Date.now()), + nowMs: verificationNowMs, resolveArtifact, verifyAuthorityEnvelope, verifyCurrentBundle, }); signal.throwIfAborted(); + assertActiveHeadFreshV1( + candidate.head, + receiverNowMs(nowMs?.() ?? Date.now()), + ); const outcome = await consumeCandidate(candidate, signal); // Atomic apply is the point of no return. A cancellation that arrives // after the storage closure returns must not hide a committed outcome and @@ -207,15 +212,12 @@ async function buildVerifiedActiveCandidateFactsV1( ); assertRowBindsHead(networkId, row, envelope); assertActiveHeadEnvelopeV1(envelope); - if (Date.parse(envelope.object.validUntil) <= nowMs) { - throw new Error('active profile receiver resolved an expired agent-profile head'); - } + assertActiveHeadFreshV1(envelope.object, nowMs); const { closure, verifiedBundle } = await verifyActiveProfileClosureForRowV1({ row, signal, nowMs, - envelope, currentHeadArtifact, resolveArtifact, verifyAuthorityEnvelope, @@ -265,7 +267,6 @@ async function buildVerifiedActiveCandidateFactsV1( interface VerifyActiveProfileClosureOptionsV1 extends Omit { - readonly envelope: SignedAgentProfileActiveHeadEnvelopeV1; readonly currentHeadArtifact: SystemRecordArtifactV1; } @@ -276,42 +277,23 @@ async function verifyActiveProfileClosureForRowV1( row, signal, nowMs, - envelope, currentHeadArtifact, resolveArtifact, verifyAuthorityEnvelope, verifyCurrentBundle, } = options; - signal.throwIfAborted(); - const resolvedCurrentBundleArtifact = await resolveArtifact({ - type: 'object', - objectKind: 'profile-bundle', - objectDigest: envelope.object.bundleDigest, - }, signal); - signal.throwIfAborted(); - if (resolvedCurrentBundleArtifact === null) { - throw new Error(`verification closure is missing ${envelope.object.bundleDigest}`); - } - const currentBundleArtifact = snapshotExpectedArtifactV1( - resolvedCurrentBundleArtifact, - 'profile-bundle', - envelope.object.bundleDigest, - 'closure artifact', + const bundleVerification = createExactOnceBundleVerificationResultV1( + verifyCurrentBundle, + signal, ); const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { nowMs, resolve: async (reference) => { signal.throwIfAborted(); - let owned: SystemRecordArtifactV1 | undefined; - if (reference.objectKind === 'agent-profile-head' - && reference.digest === row.headDigest) { - owned = currentHeadArtifact; - } else if (reference.objectKind === 'profile-bundle' - && reference.digest === envelope.object.bundleDigest) { - owned = currentBundleArtifact; - } else { - owned = await resolveClosureArtifactV1(reference, resolveArtifact, signal); - } + const owned = reference.objectKind === 'agent-profile-head' + && reference.digest === row.headDigest + ? currentHeadArtifact + : await resolveClosureArtifactV1(reference, resolveArtifact, signal); signal.throwIfAborted(); if (owned === undefined) return undefined; return Object.freeze({ @@ -326,21 +308,51 @@ async function verifyActiveProfileClosureForRowV1( signal.throwIfAborted(); return verified === true; }, - verifyCurrentBundle: (head, canonicalBundleBytes) => - head.bundleDigest === envelope.object.bundleDigest - && systemRecordBytesEqualV1(canonicalBundleBytes, currentBundleArtifact.canonicalBytes), + verifyCurrentBundle: bundleVerification.verify, }); - signal.throwIfAborted(); - const result = await verifyCurrentBundle( - envelope.object, - Uint8Array.from(currentBundleArtifact.canonicalBytes), - signal, - ); - signal.throwIfAborted(); - const decoded = decodeOpaqueKaBundleV1(currentBundleArtifact.canonicalBytes); + return bundleVerification.complete(closure); +} + +interface ExactOnceBundleVerificationResultV1 { + readonly verify: ( + head: AgentProfileActiveHeadObjectV1, + canonicalBundleBytes: Uint8Array, + ) => Promise; + readonly complete: ( + closure: SystemRecordVerificationClosureV1, + ) => VerifiedActiveProfileClosureV1; +} + +function createExactOnceBundleVerificationResultV1( + verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle'], + signal: AbortSignal, +): ExactOnceBundleVerificationResultV1 { + let invoked = false; + let verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle'] | undefined; return Object.freeze({ - closure, - verifiedBundle: snapshotVerifiedBundle(result, decoded.projectionBytes), + verify: async ( + head: AgentProfileActiveHeadObjectV1, + canonicalBundleBytes: Uint8Array, + ) => { + if (invoked) throw new Error('active profile bundle verification must run exactly once'); + invoked = true; + signal.throwIfAborted(); + const result = await verifyCurrentBundle( + head, + Uint8Array.from(canonicalBundleBytes), + signal, + ); + signal.throwIfAborted(); + const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); + verifiedBundle = snapshotVerifiedBundle(result, decoded.projectionBytes); + return true; + }, + complete: (closure: SystemRecordVerificationClosureV1) => { + if (!invoked || verifiedBundle === undefined) { + throw new Error('active profile receiver resolved a non-active verification closure'); + } + return Object.freeze({ closure, verifiedBundle }); + }, }); } @@ -446,9 +458,10 @@ function snapshotVerifiedBundle( }); } -function systemRecordBytesEqualV1(left: Uint8Array, right: Uint8Array): boolean { - return left.byteLength === right.byteLength - && left.every((byte, index) => byte === right[index]); +function assertActiveHeadFreshV1(head: AgentProfileActiveHeadObjectV1, nowMs: number): void { + if (Date.parse(head.validUntil) <= nowMs) { + throw new Error('active profile receiver resolved an expired agent-profile head'); + } } function receiverNowMs(value: number): number { diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index e9bafd34c4..b941301b01 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -8,13 +8,17 @@ import { deriveAgentProfileOwnedSubjectV1, EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, SYSTEM_RECORD_OBJECT_CAPS_V1, + type AgentProfileAuthorityTransitionV1, type AgentProfileHeadObjectV1, type OwnedSubjectTableObjectV1, type SystemRecordInventoryRowV1, } from '@origintrail-official/dkg-core/system-record-v1'; +import { ethers } from 'ethers'; import { parseNQuads } from '../src/dkg-agent-utils.js'; +import { createEvmPersonalMessageSignerV1 } from '../src/evm-message-signer-v1.js'; import { prepareAgentProfileV1 } from '../src/profile.js'; +import { createInMemoryAgentProfilePublicationStoreV1 } from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; import { createAgentProfileReceiverV1, type AgentProfileReceiverCandidateV1, @@ -23,12 +27,15 @@ import { createFixtureAgentProfileProducerV1, DEPLOYMENT, envelopeArtifact, + makePrepared, NETWORK, + OTHER_PRIVATE_KEY, produce, producerFixture, PRODUCER_FIXTURE_NOW_MS, publicationFor, signHeadEnvelope, + signTransitionEnvelope, } from './support/agent-profile-producer-v1-fixture.js'; async function publishedFixture(withDerivedSubjects = false) { @@ -79,6 +86,90 @@ async function publishedFixture(withDerivedSubjects = false) { return { ...fixture, prepared, publication, envelope, row }; } +async function rotatedPublishedFixture() { + const prior = await publishedFixture(); + const nextSigner = createEvmPersonalMessageSignerV1({ + mode: 'custodial', + address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, + privateKey: OTHER_PRIVATE_KEY, + purpose: 'receiver post-transition test', + }); + const prepared = makePrepared( + prior.peerSigner, + nextSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const publication = await publicationFor( + prepared, + nextSigner.address, + '2026-08-07T12:20:00Z', + OTHER_PRIVATE_KEY, + ); + const currentStore = createInMemoryAgentProfilePublicationStoreV1(); + await produce(createFixtureAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: prior.peerSigner, + evmSigner: nextSigner, + store: currentStore, + fence: () => undefined, + install: () => undefined, + }), prepared, publication); + const bootstrapEnvelope = currentStore.snapshot().currentHead; + if (bootstrapEnvelope === null) throw new Error('rotated fixture did not publish a head'); + const transition: AgentProfileAuthorityTransitionV1 = Object.freeze({ + objectType: 'authority-transition', + kind: 'agents', + mode: 'co-signed', + networkId: NETWORK, + peerId: prior.peerSigner.peerId, + peerPublicKey: prior.peerSigner.publicKey, + priorAuthoritySequence: '0', + nextAuthoritySequence: '1', + priorHeadDigest: prior.envelope.objectDigest, + priorEvmIssuer: prior.evmSigner.address, + nextEvmIssuer: nextSigner.address, + nextRoot: prepared.rootEntity, + issuedAt: '2026-08-07T12:10:00Z', + }); + const transitionEnvelope = await signTransitionEnvelope( + transition, + prior.peerSigner, + prior.evmSigner, + nextSigner, + ); + const envelope = await signHeadEnvelope(Object.freeze({ + ...bootstrapEnvelope.object, + authoritySequence: '1', + acceptedTransitionDigest: transitionEnvelope.objectDigest, + }), prior.peerSigner, nextSigner); + const currentHeadArtifact = envelopeArtifact('agent-profile-head', envelope); + const transitionArtifact = envelopeArtifact('authority-transition', transitionEnvelope); + const priorHeadArtifact = envelopeArtifact('agent-profile-head', prior.envelope); + const resolve = vi.fn(async (lookup, signal) => { + if (lookup.type === 'object') { + if (lookup.objectKind === currentHeadArtifact.objectKind + && lookup.objectDigest === currentHeadArtifact.objectDigest) return currentHeadArtifact; + if (lookup.objectKind === transitionArtifact.objectKind + && lookup.objectDigest === transitionArtifact.objectDigest) return transitionArtifact; + if (lookup.objectKind === priorHeadArtifact.objectKind + && lookup.objectDigest === priorHeadArtifact.objectDigest) return priorHeadArtifact; + } + return currentStore.resolve(lookup, signal); + }); + const head = envelope.object; + const row: SystemRecordInventoryRowV1 = Object.freeze({ + stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), + peerId: head.peerId, + authoritySequence: head.authoritySequence, + version: head.version, + headDigest: envelope.objectDigest, + tombstone: false, + quarantined: false, + }); + return { prior, prepared, envelope, transitionEnvelope, resolve, row }; +} + function verifiedFixtureBundle(bundleBytes: Uint8Array) { const { projectionBytes } = decodeOpaqueKaBundleV1(bundleBytes); return Object.freeze({ @@ -222,6 +313,31 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('rechecks freshness immediately before the materialization point of no return', async () => { + const fixture = await publishedFixture(); + const validUntilMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilMs - 1) + .mockReturnValue(validUntilMs); + const verifyCurrentBundle = vi.fn( + (_head, bundleBytes: Uint8Array) => verifiedFixtureBundle(bundleBytes), + ); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/expired agent-profile head/); + expect(nowMs).toHaveBeenCalledTimes(2); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('hands every derived owned subject to the materializer candidate', async () => { const fixture = await publishedFixture(true); const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ @@ -251,6 +367,79 @@ describe('agent-profile system-record active receiver', () => { expect(candidate.envelope.object.state).toBe('active'); }); + it('traverses post-transition authority history and hands off its verified lineage', async () => { + const fixture = await rotatedPublishedFixture(); + const verifyAuthorityEnvelope = vi.fn(() => true); + const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ + outcome: 'applied' as const, + stateRevision: '5', + appliedStateDigest: `0x${'9'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { resolve: fixture.resolve }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyAuthorityEnvelope, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + const resolvedKinds = fixture.resolve.mock.calls + .map(([lookup]) => lookup.type === 'object' ? lookup.objectKind : lookup.type); + expect(resolvedKinds).toEqual(expect.arrayContaining([ + 'agent-profile-head', + 'profile-bundle', + 'authority-transition', + 'owned-subject-table', + ])); + expect(verifyAuthorityEnvelope.mock.calls.map(([candidate]) => candidate.object.objectType)) + .toEqual([ + 'agent-profile-head', + 'authority-transition', + 'agent-profile-head', + ]); + const candidate = consumeCandidate.mock.calls[0]![0]; + expect(candidate.verifiedAuthoritySummary).toMatchObject({ + candidateHeadDigest: fixture.envelope.objectDigest, + transitionLineage: [{ + priorAuthoritySequence: '0', + nextAuthoritySequence: '1', + transitionDigest: fixture.transitionEnvelope.objectDigest, + }], + historicalRoots: [fixture.prior.envelope.object.rootSubject], + lastAuthorityTransitionPriorHeadDigest: fixture.prior.envelope.objectDigest, + }); + }); + + it.each(['missing', 'refused'] as const)( + 'fails closed when post-transition authority evidence is $condition', + async (condition) => { + const fixture = await rotatedPublishedFixture(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { + resolve: (lookup, signal) => condition === 'missing' + && lookup.type === 'object' + && lookup.objectKind === 'authority-transition' + ? Promise.resolve(null) + : fixture.resolve(lookup, signal), + }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyAuthorityEnvelope: (candidate) => condition !== 'refused' + || candidate.object.objectType !== 'authority-transition', + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(condition === 'missing' ? /missing/ : /authority-transition verification/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }, + ); + it('fails closed when the exact owned-subject table is unavailable', async () => { const fixture = await publishedFixture(); const consumeCandidate = vi.fn(); From 57234b59b7f2e0845cb550845ee6c411983073f9 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 23:48:22 +0200 Subject: [PATCH 12/18] fix(agent): bind verified apply admission --- .../agent/src/system-records/receiver-v1.ts | 106 ++++++++++++++++-- .../test/system-record-receiver-v1.test.ts | 104 +++++++++++++++++ 2 files changed, 203 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 0a03b62cde..2051e23834 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -2,6 +2,7 @@ import { decodeOpaqueKaBundleV1, + tripleContentV10, } from '@origintrail-official/dkg-core'; import { buildAgentProfileVerificationClosureV1, @@ -38,6 +39,7 @@ import { type SystemRecordArtifactRepositoryV1, type SystemRecordArtifactV1, } from './artifact-v1.js'; +import { parseNQuads } from '../dkg-agent-utils.js'; export interface AgentProfileReceiverVerifiedBundleV1 { /** Exact canonical projection bytes parsed and authenticated from the supplied bundle. */ @@ -60,6 +62,18 @@ export interface AgentProfileReceiverCandidateV1 { readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; } +/** Expiry admission that the lifecycle-owned materializer must bind to storage. */ +export interface AgentProfileReceiverApplyAdmissionV1 { + /** Absolute signed-head deadline to use as the storage admitted deadline. */ + readonly validUntilMs: number; + /** + * Re-read the receiver clock and reject an expired head. The lifecycle bridge + * must call this after its waits, immediately before proof issuance/apply + * admission, so the storage operation cannot admit an already-expired head. + */ + readonly assertFreshAtApply: () => void; +} + export interface CreateAgentProfileReceiverOptionsV1 { readonly networkId: NetworkIdV1; readonly artifacts: SystemRecordArtifactRepositoryV1; @@ -83,10 +97,14 @@ export interface CreateAgentProfileReceiverOptionsV1 { /** * Lifecycle-owned bridge into the storage runtime. It mints and consumes the * private replacement proof inside one structured call; no proof escapes. + * It must bind admission.validUntilMs to the storage admitted deadline and, + * after any internal await, call admission.assertFreshAtApply immediately + * before proof issuance/apply admission. */ readonly consumeCandidate: ( input: AgentProfileReceiverCandidateV1, signal: AbortSignal, + admission: AgentProfileReceiverApplyAdmissionV1, ) => SystemRecordApplyOutcomeV1 | Promise; readonly nowMs?: () => number; } @@ -146,11 +164,16 @@ export function createAgentProfileReceiverV1( verifyCurrentBundle, }); signal.throwIfAborted(); - assertActiveHeadFreshV1( - candidate.head, - receiverNowMs(nowMs?.() ?? Date.now()), - ); - const outcome = await consumeCandidate(candidate, signal); + const validUntilMs = Date.parse(candidate.head.validUntil); + const admission: AgentProfileReceiverApplyAdmissionV1 = Object.freeze({ + validUntilMs, + assertFreshAtApply: () => assertActiveDeadlineFreshV1( + validUntilMs, + receiverNowMs(nowMs?.() ?? Date.now()), + ), + }); + admission.assertFreshAtApply(); + const outcome = await consumeCandidate(candidate, signal, admission); // Atomic apply is the point of no return. A cancellation that arrives // after the storage closure returns must not hide a committed outcome and // make the caller retry it as if nothing happened. @@ -446,12 +469,16 @@ function snapshotVerifiedBundle( || suppliedProjectionBytes.some((byte, index) => byte !== expectedProjectionBytes[index])) { throw new Error('bundle verifier projection does not bind the supplied bundle'); } - const projectionQuads = value.projectionQuads.map((quad) => Object.freeze({ + const suppliedProjectionQuads = value.projectionQuads.map((quad) => Object.freeze({ subject: quad.subject, predicate: quad.predicate, object: quad.object, graph: quad.graph, })); + const projectionQuads = deriveCanonicalProjectionQuadsV1(expectedProjectionBytes); + if (!equalQuadMultisetsV1(suppliedProjectionQuads, projectionQuads)) { + throw new Error('bundle verifier projection quads do not bind the supplied bundle'); + } return Object.freeze({ canonicalProjectionBytes: Uint8Array.from(expectedProjectionBytes), projectionQuads: Object.freeze(projectionQuads), @@ -459,11 +486,76 @@ function snapshotVerifiedBundle( } function assertActiveHeadFreshV1(head: AgentProfileActiveHeadObjectV1, nowMs: number): void { - if (Date.parse(head.validUntil) <= nowMs) { + assertActiveDeadlineFreshV1(Date.parse(head.validUntil), nowMs); +} + +function assertActiveDeadlineFreshV1(validUntilMs: number, nowMs: number): void { + if (validUntilMs <= nowMs) { throw new Error('active profile receiver resolved an expired agent-profile head'); } } +function deriveCanonicalProjectionQuadsV1( + canonicalProjectionBytes: Uint8Array, +): Readonly[] { + let projectionText: string; + try { + projectionText = new TextDecoder('utf-8', { fatal: true }).decode(canonicalProjectionBytes); + } catch { + throw new Error('bundle verifier projection bytes are not valid UTF-8'); + } + const quads = parseNQuads(projectionText).map((quad) => Object.freeze({ + subject: quad.subject, + predicate: quad.predicate, + object: quad.object, + graph: quad.graph, + })); + const reconstructed = new Uint8Array( + quads.reduce((total, quad) => total + tripleContentV10( + quad.subject, + quad.predicate, + quad.object, + ).byteLength + 1, 0), + ); + let offset = 0; + for (const quad of quads) { + if (quad.graph !== '') { + throw new Error('bundle verifier projection must be graphless'); + } + const line = tripleContentV10(quad.subject, quad.predicate, quad.object); + reconstructed.set(line, offset); + offset += line.byteLength; + reconstructed[offset] = 0x0a; + offset += 1; + } + if (reconstructed.byteLength !== canonicalProjectionBytes.byteLength + || reconstructed.some((byte, index) => byte !== canonicalProjectionBytes[index])) { + throw new Error('bundle verifier projection bytes do not encode exact canonical quads'); + } + return quads; +} + +function equalQuadMultisetsV1( + left: readonly Readonly[], + right: readonly Readonly[], +): boolean { + if (left.length !== right.length) return false; + const sortedLeft = [...left].sort(compareQuadsV1); + const sortedRight = [...right].sort(compareQuadsV1); + return sortedLeft.every((quad, index) => compareQuadsV1(quad, sortedRight[index]!) === 0); +} + +function compareQuadsV1(left: Readonly, right: Readonly): number { + return compareStringsV1(left.subject, right.subject) + || compareStringsV1(left.predicate, right.predicate) + || compareStringsV1(left.object, right.object) + || compareStringsV1(left.graph, right.graph); +} + +function compareStringsV1(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function receiverNowMs(value: number): number { if (!Number.isSafeInteger(value) || value < 0) { throw new Error('agent-profile receiver clock returned an invalid value'); diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index b941301b01..87a94e42d5 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -21,6 +21,7 @@ import { prepareAgentProfileV1 } from '../src/profile.js'; import { createInMemoryAgentProfilePublicationStoreV1 } from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; import { createAgentProfileReceiverV1, + type AgentProfileReceiverApplyAdmissionV1, type AgentProfileReceiverCandidateV1, } from '../src/system-records/receiver-v1.js'; import { @@ -222,6 +223,11 @@ describe('agent-profile system-record active receiver', () => { ); expect(candidate).not.toHaveProperty('signal'); expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); + expect(consumeCandidate.mock.calls[0]![2]).toMatchObject({ + validUntilMs: Date.parse(fixture.envelope.object.validUntil), + assertFreshAtApply: expect.any(Function), + }); + expect(Object.isFrozen(consumeCandidate.mock.calls[0]![2])).toBe(true); }); it('does not invoke active bundle verification for a non-active current head', async () => { @@ -338,6 +344,44 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('lets the lifecycle bridge reject expiry after its own asynchronous admission work', async () => { + const fixture = await publishedFixture(); + const validUntilMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilMs - 2) + .mockReturnValueOnce(validUntilMs - 1) + .mockReturnValue(validUntilMs); + let committed = false; + const consumeCandidate = vi.fn(async ( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + admission: AgentProfileReceiverApplyAdmissionV1, + ) => { + expect(admission.validUntilMs).toBe(validUntilMs); + await Promise.resolve(); + admission.assertFreshAtApply(); + committed = true; + return { + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + }; + }); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/expired agent-profile head/); + expect(nowMs).toHaveBeenCalledTimes(3); + expect(consumeCandidate).toHaveBeenCalledTimes(1); + expect(committed).toBe(false); + }); + it('hands every derived owned subject to the materializer candidate', async () => { const fixture = await publishedFixture(true); const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ @@ -514,6 +558,33 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('fails closed when verified projection quads do not bind their authenticated bytes', async () => { + const fixture = await publishedFixture(); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: (_head, bundleBytes) => { + const verified = verifiedFixtureBundle(bundleBytes); + return Object.freeze({ + ...verified, + projectionQuads: Object.freeze([Object.freeze({ + subject: 'urn:unrelated:subject', + predicate: 'urn:unrelated:predicate', + object: '"unrelated"', + graph: '', + })]), + }); + }, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/projection quads do not bind the supplied bundle/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('isolates signed bundle bytes from mutations by the injected verifier', async () => { const fixture = await publishedFixture(); const signal = new AbortController().signal; @@ -682,6 +753,39 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('fails closed when the default authority verifier sees a corrupted head signature', async () => { + const fixture = await publishedFixture(); + const signature = fixture.envelope.signatures[0]!.signature; + const corruptedEnvelope = Object.freeze({ + ...fixture.envelope, + signatures: Object.freeze(fixture.envelope.signatures.map((entry, index) => index === 0 + ? Object.freeze({ + ...entry, + signature: `${signature.startsWith('A') ? 'B' : 'A'}${signature.slice(1)}`, + }) + : entry)), + }) as typeof fixture.envelope; + const corruptedArtifact = envelopeArtifact('agent-profile-head', corruptedEnvelope); + const consumeCandidate = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: { + resolve: (lookup, signal) => lookup.type === 'object' + && lookup.objectKind === 'agent-profile-head' + && lookup.objectDigest === fixture.envelope.objectDigest + ? Promise.resolve(corruptedArtifact) + : fixture.store.resolve(lookup, signal), + }, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/authority verification failed/); + expect(consumeCandidate).not.toHaveBeenCalled(); + }); + it('rejects an oversized artifact before invoking typed-array copy hooks', async () => { const fixture = await publishedFixture(); const consumeCandidate = vi.fn(); From 69b358ccb5f808e3ef817978317a727cc0376536 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sun, 9 Aug 2026 23:53:28 +0200 Subject: [PATCH 13/18] fix(agent): translate signed expiry to monotonic time --- .../agent/src/system-records/receiver-v1.ts | 35 ++++++++------ .../test/system-record-receiver-v1.test.ts | 47 ++++++++++++++++++- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 2051e23834..df2ca1f50f 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -62,16 +62,18 @@ export interface AgentProfileReceiverCandidateV1 { readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; } -/** Expiry admission that the lifecycle-owned materializer must bind to storage. */ +/** Expiry admission for translation onto the lifecycle bridge's monotonic clock. */ export interface AgentProfileReceiverApplyAdmissionV1 { - /** Absolute signed-head deadline to use as the storage admitted deadline. */ - readonly validUntilMs: number; + /** Signed-head expiry as Unix wall-clock milliseconds; not a storage deadline. */ + readonly validUntilUnixMs: number; /** - * Re-read the receiver clock and reject an expired head. The lifecycle bridge - * must call this after its waits, immediately before proof issuance/apply - * admission, so the storage operation cannot admit an already-expired head. + * Re-read the wall clock, reject expiry, and return the positive remaining + * lifetime in milliseconds. After its waits, the lifecycle bridge must first + * capture its monotonic clock, call this method, and clamp its existing + * monotonic deadline to `monotonicNow + remainingMs` immediately before proof + * issuance/apply admission. It must never pass validUntilUnixMs to storage. */ - readonly assertFreshAtApply: () => void; + readonly assertFreshAtApply: () => number; } export interface CreateAgentProfileReceiverOptionsV1 { @@ -97,15 +99,16 @@ export interface CreateAgentProfileReceiverOptionsV1 { /** * Lifecycle-owned bridge into the storage runtime. It mints and consumes the * private replacement proof inside one structured call; no proof escapes. - * It must bind admission.validUntilMs to the storage admitted deadline and, - * after any internal await, call admission.assertFreshAtApply immediately - * before proof issuance/apply admission. + * After any internal await, it must translate admission's remaining wall-clock + * lifetime onto its monotonic deadline immediately before proof issuance/apply + * admission, as specified by AgentProfileReceiverApplyAdmissionV1. */ readonly consumeCandidate: ( input: AgentProfileReceiverCandidateV1, signal: AbortSignal, admission: AgentProfileReceiverApplyAdmissionV1, ) => SystemRecordApplyOutcomeV1 | Promise; + /** Unix wall-clock milliseconds, injectable for deterministic verification. */ readonly nowMs?: () => number; } @@ -164,11 +167,11 @@ export function createAgentProfileReceiverV1( verifyCurrentBundle, }); signal.throwIfAborted(); - const validUntilMs = Date.parse(candidate.head.validUntil); + const validUntilUnixMs = Date.parse(candidate.head.validUntil); const admission: AgentProfileReceiverApplyAdmissionV1 = Object.freeze({ - validUntilMs, + validUntilUnixMs, assertFreshAtApply: () => assertActiveDeadlineFreshV1( - validUntilMs, + validUntilUnixMs, receiverNowMs(nowMs?.() ?? Date.now()), ), }); @@ -489,10 +492,12 @@ function assertActiveHeadFreshV1(head: AgentProfileActiveHeadObjectV1, nowMs: nu assertActiveDeadlineFreshV1(Date.parse(head.validUntil), nowMs); } -function assertActiveDeadlineFreshV1(validUntilMs: number, nowMs: number): void { - if (validUntilMs <= nowMs) { +function assertActiveDeadlineFreshV1(validUntilUnixMs: number, nowUnixMs: number): number { + const remainingMs = validUntilUnixMs - nowUnixMs; + if (remainingMs <= 0) { throw new Error('active profile receiver resolved an expired agent-profile head'); } + return remainingMs; } function deriveCanonicalProjectionQuadsV1( diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 87a94e42d5..44272047ea 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -224,7 +224,7 @@ describe('agent-profile system-record active receiver', () => { expect(candidate).not.toHaveProperty('signal'); expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); expect(consumeCandidate.mock.calls[0]![2]).toMatchObject({ - validUntilMs: Date.parse(fixture.envelope.object.validUntil), + validUntilUnixMs: Date.parse(fixture.envelope.object.validUntil), assertFreshAtApply: expect.any(Function), }); expect(Object.isFrozen(consumeCandidate.mock.calls[0]![2])).toBe(true); @@ -344,6 +344,49 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); + it('returns remaining Unix lifetime for the bridge to clamp its monotonic deadline', async () => { + const fixture = await publishedFixture(); + const validUntilUnixMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilUnixMs - 100) + .mockReturnValueOnce(validUntilUnixMs - 80) + .mockReturnValue(validUntilUnixMs - 60); + const existingMonotonicDeadlineMs = 5_200; + const monotonicNowMs = 5_000; + let admittedDeadlineMs: number | undefined; + const consumeCandidate = vi.fn(async ( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + admission: AgentProfileReceiverApplyAdmissionV1, + ) => { + expect(admission.validUntilUnixMs).toBe(validUntilUnixMs); + const remainingMs = admission.assertFreshAtApply(); + expect(remainingMs).toBe(60); + admittedDeadlineMs = Math.min( + existingMonotonicDeadlineMs, + monotonicNowMs + remainingMs, + ); + return { + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + }; + }); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(nowMs).toHaveBeenCalledTimes(3); + expect(admittedDeadlineMs).toBe(5_060); + expect(admittedDeadlineMs).not.toBe(validUntilUnixMs); + }); + it('lets the lifecycle bridge reject expiry after its own asynchronous admission work', async () => { const fixture = await publishedFixture(); const validUntilMs = Date.parse(fixture.envelope.object.validUntil); @@ -357,7 +400,7 @@ describe('agent-profile system-record active receiver', () => { _signal: AbortSignal, admission: AgentProfileReceiverApplyAdmissionV1, ) => { - expect(admission.validUntilMs).toBe(validUntilMs); + expect(admission.validUntilUnixMs).toBe(validUntilMs); await Promise.resolve(); admission.assertFreshAtApply(); committed = true; From 628cc72ca475b3ef723f34e8a7fedbb66ae4e781 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 10 Aug 2026 00:15:42 +0200 Subject: [PATCH 14/18] refactor(agent): own verified projection and fresh apply --- .../agent/src/system-records/receiver-v1.ts | 290 +++++++++--------- .../test/system-record-receiver-v1.test.ts | 264 ++++++++-------- 2 files changed, 293 insertions(+), 261 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index df2ca1f50f..529e1707e1 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -41,13 +41,6 @@ import { } from './artifact-v1.js'; import { parseNQuads } from '../dkg-agent-utils.js'; -export interface AgentProfileReceiverVerifiedBundleV1 { - /** Exact canonical projection bytes parsed and authenticated from the supplied bundle. */ - readonly canonicalProjectionBytes: Uint8Array; - /** Exact graphless quads parsed from canonicalProjectionBytes. */ - readonly projectionQuads: readonly Readonly[]; -} - export type SignedAgentProfileActiveHeadEnvelopeV1 = SignedAgentProfileHeadEnvelopeV1 & { readonly object: AgentProfileActiveHeadObjectV1; }; @@ -62,18 +55,34 @@ export interface AgentProfileReceiverCandidateV1 { readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; } -/** Expiry admission for translation onto the lifecycle bridge's monotonic clock. */ -export interface AgentProfileReceiverApplyAdmissionV1 { - /** Signed-head expiry as Unix wall-clock milliseconds; not a storage deadline. */ - readonly validUntilUnixMs: number; +export interface AgentProfileReceiverMonotonicApplyTimingV1 { + /** Existing authenticated Storage deadline in the bridge's monotonic clock domain. */ + readonly existingMonotonicDeadlineMs: number; + /** `Math.floor(performance.now())` captured after bridge waits. */ + readonly monotonicNowMs: number; +} + +const FRESH_APPLY_OUTCOME_V1: unique symbol = Symbol('agent-profile-fresh-apply-outcome-v1'); + +interface AgentProfileReceiverFreshApplyResultV1 { + readonly [FRESH_APPLY_OUTCOME_V1]: SystemRecordApplyOutcomeV1; +} + +/** Receiver-owned one-shot entry into lifecycle proof issuance and atomic apply. */ +export interface AgentProfileReceiverFreshApplyCapabilityV1 { /** - * Re-read the wall clock, reject expiry, and return the positive remaining - * lifetime in milliseconds. After its waits, the lifecycle bridge must first - * capture its monotonic clock, call this method, and clamp its existing - * monotonic deadline to `monotonicNow + remainingMs` immediately before proof - * issuance/apply admission. It must never pass validUntilUnixMs to storage. + * After its preparation waits, the lifecycle bridge supplies its authenticated + * existing deadline and freshly captured monotonic time. The receiver checks + * signed wall-clock freshness, clamps the monotonic deadline, and immediately + * invokes apply. The callback receives no Unix timestamp and must begin proof + * issuance/apply admission with the supplied deadline. */ - readonly assertFreshAtApply: () => number; + readonly admitFreshApply: ( + timing: AgentProfileReceiverMonotonicApplyTimingV1, + apply: ( + admittedDeadlineMs: number, + ) => SystemRecordApplyOutcomeV1 | Promise, + ) => Promise; } export interface CreateAgentProfileReceiverOptionsV1 { @@ -90,24 +99,24 @@ export interface CreateAgentProfileReceiverOptionsV1 { | SignedAgentProfileForkResolutionEnvelopeV1, signal: AbortSignal, ) => boolean | Promise; - /** Final graph-scoped publication/seal verification of one coherent projection. */ + /** Final graph-scoped publication/seal acceptance of the exact supplied bundle. */ readonly verifyCurrentBundle: ( head: AgentProfileActiveHeadObjectV1, canonicalBundleBytes: Uint8Array, signal: AbortSignal, - ) => AgentProfileReceiverVerifiedBundleV1 | Promise; + ) => boolean | Promise; /** * Lifecycle-owned bridge into the storage runtime. It mints and consumes the * private replacement proof inside one structured call; no proof escapes. - * After any internal await, it must translate admission's remaining wall-clock - * lifetime onto its monotonic deadline immediately before proof issuance/apply - * admission, as specified by AgentProfileReceiverApplyAdmissionV1. + * Its return value must come from freshApply.admitFreshApply, so the receiver + * owns the final freshness check and monotonic-deadline clamp. */ readonly consumeCandidate: ( input: AgentProfileReceiverCandidateV1, signal: AbortSignal, - admission: AgentProfileReceiverApplyAdmissionV1, - ) => SystemRecordApplyOutcomeV1 | Promise; + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => AgentProfileReceiverFreshApplyResultV1 + | Promise; /** Unix wall-clock milliseconds, injectable for deterministic verification. */ readonly nowMs?: () => number; } @@ -168,31 +177,24 @@ export function createAgentProfileReceiverV1( }); signal.throwIfAborted(); const validUntilUnixMs = Date.parse(candidate.head.validUntil); - const admission: AgentProfileReceiverApplyAdmissionV1 = Object.freeze({ + assertActiveDeadlineFreshV1( + validUntilUnixMs, + receiverNowMs(nowMs?.() ?? Date.now()), + ); + const freshApply = createFreshApplyCapabilityV1({ validUntilUnixMs, - assertFreshAtApply: () => assertActiveDeadlineFreshV1( - validUntilUnixMs, - receiverNowMs(nowMs?.() ?? Date.now()), - ), + signal, + nowUnixMs: () => receiverNowMs(nowMs?.() ?? Date.now()), }); - admission.assertFreshAtApply(); - const outcome = await consumeCandidate(candidate, signal, admission); + const result = await consumeCandidate(candidate, signal, freshApply); // Atomic apply is the point of no return. A cancellation that arrives // after the storage closure returns must not hide a committed outcome and // make the caller retry it as if nothing happened. - return outcome; + return unwrapFreshApplyResultV1(result); }, }); } -interface VerifiedActiveProfileClosureV1 { - readonly closure: SystemRecordVerificationClosureV1; - readonly verifiedBundle: Readonly<{ - readonly projectionQuads: readonly Readonly[]; - readonly canonicalProjectionBytes: Uint8Array; - }>; -} - interface BuildVerifiedActiveCandidateFactsOptionsV1 { readonly networkId: NetworkIdV1; readonly row: SystemRecordInventoryRowV1; @@ -240,7 +242,7 @@ async function buildVerifiedActiveCandidateFactsV1( assertActiveHeadEnvelopeV1(envelope); assertActiveHeadFreshV1(envelope.object, nowMs); - const { closure, verifiedBundle } = await verifyActiveProfileClosureForRowV1({ + const closure = await verifyActiveProfileClosureForRowV1({ row, signal, nowMs, @@ -250,12 +252,24 @@ async function buildVerifiedActiveCandidateFactsV1( verifyCurrentBundle, }); signal.throwIfAborted(); - if (!closure.objects.some((artifact) => - artifact.objectKind === 'agent-profile-head' && artifact.digest === row.headDigest)) { - throw new Error('verification closure did not retain its current agent-profile head'); - } - const head = envelope.object; + requiredClosureArtifactV1( + closure, + 'agent-profile-head', + row.headDigest, + 'current agent-profile head', + ); + const bundleArtifact = requiredClosureArtifactV1( + closure, + 'profile-bundle', + head.bundleDigest, + 'current profile bundle', + ); + const decodedBundle = decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes); + const canonicalProjectionBytes = Uint8Array.from(decodedBundle.projectionBytes); + const projectionQuads = Object.freeze( + deriveCanonicalProjectionQuadsV1(canonicalProjectionBytes), + ); const resolvedSubjectTableArtifact = await resolveArtifact({ type: 'object', objectKind: 'owned-subject-table', @@ -284,8 +298,8 @@ async function buildVerifiedActiveCandidateFactsV1( return Object.freeze({ head, envelope, - canonicalProjectionBytes: verifiedBundle.canonicalProjectionBytes, - projectionQuads: verifiedBundle.projectionQuads, + canonicalProjectionBytes, + projectionQuads, ownedSubjectTable, verifiedAuthoritySummary: closure.authoritySummary, }); @@ -298,7 +312,7 @@ interface VerifyActiveProfileClosureOptionsV1 async function verifyActiveProfileClosureForRowV1( options: VerifyActiveProfileClosureOptionsV1, -): Promise { +): Promise { const { row, signal, @@ -308,11 +322,7 @@ async function verifyActiveProfileClosureForRowV1( verifyAuthorityEnvelope, verifyCurrentBundle, } = options; - const bundleVerification = createExactOnceBundleVerificationResultV1( - verifyCurrentBundle, - signal, - ); - const closure = await buildAgentProfileVerificationClosureV1(row.headDigest, { + return buildAgentProfileVerificationClosureV1(row.headDigest, { nowMs, resolve: async (reference) => { signal.throwIfAborted(); @@ -334,50 +344,15 @@ async function verifyActiveProfileClosureForRowV1( signal.throwIfAborted(); return verified === true; }, - verifyCurrentBundle: bundleVerification.verify, - }); - return bundleVerification.complete(closure); -} - -interface ExactOnceBundleVerificationResultV1 { - readonly verify: ( - head: AgentProfileActiveHeadObjectV1, - canonicalBundleBytes: Uint8Array, - ) => Promise; - readonly complete: ( - closure: SystemRecordVerificationClosureV1, - ) => VerifiedActiveProfileClosureV1; -} - -function createExactOnceBundleVerificationResultV1( - verifyCurrentBundle: CreateAgentProfileReceiverOptionsV1['verifyCurrentBundle'], - signal: AbortSignal, -): ExactOnceBundleVerificationResultV1 { - let invoked = false; - let verifiedBundle: VerifiedActiveProfileClosureV1['verifiedBundle'] | undefined; - return Object.freeze({ - verify: async ( - head: AgentProfileActiveHeadObjectV1, - canonicalBundleBytes: Uint8Array, - ) => { - if (invoked) throw new Error('active profile bundle verification must run exactly once'); - invoked = true; + verifyCurrentBundle: async (head, canonicalBundleBytes) => { signal.throwIfAborted(); - const result = await verifyCurrentBundle( + const verified = await verifyCurrentBundle( head, Uint8Array.from(canonicalBundleBytes), signal, ); signal.throwIfAborted(); - const decoded = decodeOpaqueKaBundleV1(canonicalBundleBytes); - verifiedBundle = snapshotVerifiedBundle(result, decoded.projectionBytes); - return true; - }, - complete: (closure: SystemRecordVerificationClosureV1) => { - if (!invoked || verifiedBundle === undefined) { - throw new Error('active profile receiver resolved a non-active verification closure'); - } - return Object.freeze({ closure, verifiedBundle }); + return verified === true; }, }); } @@ -458,36 +433,90 @@ function snapshotExpectedArtifactV1( }); } -function snapshotVerifiedBundle( - value: AgentProfileReceiverVerifiedBundleV1, - expectedProjectionBytes: Uint8Array, -): AgentProfileReceiverVerifiedBundleV1 { - if (value === null || typeof value !== 'object' - || !(value.canonicalProjectionBytes instanceof Uint8Array) - || !Array.isArray(value.projectionQuads)) { - throw new Error('bundle verifier returned an invalid projection'); - } - const suppliedProjectionBytes = value.canonicalProjectionBytes; - if (suppliedProjectionBytes.byteLength !== expectedProjectionBytes.byteLength - || suppliedProjectionBytes.some((byte, index) => byte !== expectedProjectionBytes[index])) { - throw new Error('bundle verifier projection does not bind the supplied bundle'); - } - const suppliedProjectionQuads = value.projectionQuads.map((quad) => Object.freeze({ - subject: quad.subject, - predicate: quad.predicate, - object: quad.object, - graph: quad.graph, - })); - const projectionQuads = deriveCanonicalProjectionQuadsV1(expectedProjectionBytes); - if (!equalQuadMultisetsV1(suppliedProjectionQuads, projectionQuads)) { - throw new Error('bundle verifier projection quads do not bind the supplied bundle'); +function requiredClosureArtifactV1( + closure: SystemRecordVerificationClosureV1, + objectKind: SystemRecordObjectKindV1, + digest: Digest32V1, + label: string, +): SystemRecordVerificationClosureV1['objects'][number] { + const artifact = closure.objects.find((candidate) => + candidate.objectKind === objectKind && candidate.digest === digest); + if (artifact === undefined) { + throw new Error(`verification closure did not retain its ${label}`); } + return artifact; +} + +interface CreateFreshApplyCapabilityOptionsV1 { + readonly validUntilUnixMs: number; + readonly signal: AbortSignal; + readonly nowUnixMs: () => number; +} + +function createFreshApplyCapabilityV1( + options: CreateFreshApplyCapabilityOptionsV1, +): AgentProfileReceiverFreshApplyCapabilityV1 { + const { validUntilUnixMs, signal, nowUnixMs } = options; + let used = false; return Object.freeze({ - canonicalProjectionBytes: Uint8Array.from(expectedProjectionBytes), - projectionQuads: Object.freeze(projectionQuads), + admitFreshApply: async ( + timing: AgentProfileReceiverMonotonicApplyTimingV1, + apply: ( + admittedDeadlineMs: number, + ) => SystemRecordApplyOutcomeV1 | Promise, + ): Promise => { + if (used) throw new Error('agent-profile fresh-apply capability is one-shot'); + used = true; + signal.throwIfAborted(); + if (timing === null || typeof timing !== 'object') { + throw new Error('agent-profile monotonic apply timing is invalid'); + } + const existingMonotonicDeadlineMs = monotonicApplyMsV1( + timing.existingMonotonicDeadlineMs, + 'existing deadline', + ); + const monotonicNowMs = monotonicApplyMsV1( + timing.monotonicNowMs, + 'current time', + ); + if (typeof apply !== 'function') { + throw new Error('agent-profile fresh apply callback is invalid'); + } + const remainingMs = assertActiveDeadlineFreshV1(validUntilUnixMs, nowUnixMs()); + const translatedDeadlineMs = monotonicNowMs + remainingMs; + if (!Number.isSafeInteger(translatedDeadlineMs)) { + throw new Error('agent-profile translated apply deadline is invalid'); + } + const admittedDeadlineMs = Math.min( + existingMonotonicDeadlineMs, + translatedDeadlineMs, + ); + if (admittedDeadlineMs <= monotonicNowMs) { + throw new Error('agent-profile monotonic apply admission is expired'); + } + const outcome = await apply(admittedDeadlineMs); + return Object.freeze({ [FRESH_APPLY_OUTCOME_V1]: outcome }); + }, }); } +function unwrapFreshApplyResultV1( + value: AgentProfileReceiverFreshApplyResultV1, +): SystemRecordApplyOutcomeV1 { + if (value === null || typeof value !== 'object' + || !Object.prototype.hasOwnProperty.call(value, FRESH_APPLY_OUTCOME_V1)) { + throw new Error('lifecycle bridge did not return a fresh-apply result'); + } + return value[FRESH_APPLY_OUTCOME_V1]; +} + +function monotonicApplyMsV1(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`agent-profile monotonic apply ${label} is invalid`); + } + return value; +} + function assertActiveHeadFreshV1(head: AgentProfileActiveHeadObjectV1, nowMs: number): void { assertActiveDeadlineFreshV1(Date.parse(head.validUntil), nowMs); } @@ -507,7 +536,7 @@ function deriveCanonicalProjectionQuadsV1( try { projectionText = new TextDecoder('utf-8', { fatal: true }).decode(canonicalProjectionBytes); } catch { - throw new Error('bundle verifier projection bytes are not valid UTF-8'); + throw new Error('profile bundle projection bytes are not valid UTF-8'); } const quads = parseNQuads(projectionText).map((quad) => Object.freeze({ subject: quad.subject, @@ -525,7 +554,7 @@ function deriveCanonicalProjectionQuadsV1( let offset = 0; for (const quad of quads) { if (quad.graph !== '') { - throw new Error('bundle verifier projection must be graphless'); + throw new Error('profile bundle projection must be graphless'); } const line = tripleContentV10(quad.subject, quad.predicate, quad.object); reconstructed.set(line, offset); @@ -535,32 +564,11 @@ function deriveCanonicalProjectionQuadsV1( } if (reconstructed.byteLength !== canonicalProjectionBytes.byteLength || reconstructed.some((byte, index) => byte !== canonicalProjectionBytes[index])) { - throw new Error('bundle verifier projection bytes do not encode exact canonical quads'); + throw new Error('profile bundle projection bytes do not encode exact canonical quads'); } return quads; } -function equalQuadMultisetsV1( - left: readonly Readonly[], - right: readonly Readonly[], -): boolean { - if (left.length !== right.length) return false; - const sortedLeft = [...left].sort(compareQuadsV1); - const sortedRight = [...right].sort(compareQuadsV1); - return sortedLeft.every((quad, index) => compareQuadsV1(quad, sortedRight[index]!) === 0); -} - -function compareQuadsV1(left: Readonly, right: Readonly): number { - return compareStringsV1(left.subject, right.subject) - || compareStringsV1(left.predicate, right.predicate) - || compareStringsV1(left.object, right.object) - || compareStringsV1(left.graph, right.graph); -} - -function compareStringsV1(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - function receiverNowMs(value: number): number { if (!Number.isSafeInteger(value) || value < 0) { throw new Error('agent-profile receiver clock returned an invalid value'); diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 44272047ea..a3f5a513d5 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -15,14 +15,13 @@ import { } from '@origintrail-official/dkg-core/system-record-v1'; import { ethers } from 'ethers'; -import { parseNQuads } from '../src/dkg-agent-utils.js'; import { createEvmPersonalMessageSignerV1 } from '../src/evm-message-signer-v1.js'; import { prepareAgentProfileV1 } from '../src/profile.js'; import { createInMemoryAgentProfilePublicationStoreV1 } from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; import { createAgentProfileReceiverV1, - type AgentProfileReceiverApplyAdmissionV1, type AgentProfileReceiverCandidateV1, + type AgentProfileReceiverFreshApplyCapabilityV1, } from '../src/system-records/receiver-v1.js'; import { createFixtureAgentProfileProducerV1, @@ -171,12 +170,21 @@ async function rotatedPublishedFixture() { return { prior, prepared, envelope, transitionEnvelope, resolve, row }; } -function verifiedFixtureBundle(bundleBytes: Uint8Array) { - const { projectionBytes } = decodeOpaqueKaBundleV1(bundleBytes); - return Object.freeze({ - canonicalProjectionBytes: Uint8Array.from(projectionBytes), - projectionQuads: Object.freeze(parseNQuads(new TextDecoder().decode(projectionBytes))), - }); +const DEFAULT_MONOTONIC_APPLY_TIMING = Object.freeze({ + existingMonotonicDeadlineMs: 10_000, + monotonicNowMs: 1_000, +}); + +function admitFixtureApply( + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + stateRevision: string, + digestCharacter: string, +) { + return freshApply.admitFreshApply(DEFAULT_MONOTONIC_APPLY_TIMING, () => ({ + outcome: 'applied', + stateRevision, + appliedStateDigest: `0x${digestCharacter.repeat(64)}`, + })); } describe('agent-profile system-record active receiver', () => { @@ -193,13 +201,13 @@ describe('agent-profile system-record active receiver', () => { expect(head).toEqual(fixture.envelope.object); expect(bundleBytes).toEqual(bundleArtifact.canonicalBytes); expect(receivedSignal).toBe(signal); - return verifiedFixtureBundle(bundleBytes); + return true; }); - const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ - outcome: 'applied' as const, - stateRevision: '1', - appliedStateDigest: `0x${'a'.repeat(64)}`, - })); + const consumeCandidate = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => admitFixtureApply(freshApply, '1', 'a')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, @@ -224,9 +232,9 @@ describe('agent-profile system-record active receiver', () => { expect(candidate).not.toHaveProperty('signal'); expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); expect(consumeCandidate.mock.calls[0]![2]).toMatchObject({ - validUntilUnixMs: Date.parse(fixture.envelope.object.validUntil), - assertFreshAtApply: expect.any(Function), + admitFreshApply: expect.any(Function), }); + expect(consumeCandidate.mock.calls[0]![2]).not.toHaveProperty('validUntilUnixMs'); expect(Object.isFrozen(consumeCandidate.mock.calls[0]![2])).toBe(true); }); @@ -326,7 +334,7 @@ describe('agent-profile system-record active receiver', () => { .mockReturnValueOnce(validUntilMs - 1) .mockReturnValue(validUntilMs); const verifyCurrentBundle = vi.fn( - (_head, bundleBytes: Uint8Array) => verifiedFixtureBundle(bundleBytes), + () => true, ); const consumeCandidate = vi.fn(); const receiver = createAgentProfileReceiverV1({ @@ -344,7 +352,7 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); - it('returns remaining Unix lifetime for the bridge to clamp its monotonic deadline', async () => { + it('clamps signed wall-clock expiry onto the bridge monotonic deadline', async () => { const fixture = await publishedFixture(); const validUntilUnixMs = Date.parse(fixture.envelope.object.validUntil); const nowMs = vi.fn() @@ -354,29 +362,26 @@ describe('agent-profile system-record active receiver', () => { const existingMonotonicDeadlineMs = 5_200; const monotonicNowMs = 5_000; let admittedDeadlineMs: number | undefined; - const consumeCandidate = vi.fn(async ( + const consumeCandidate = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - admission: AgentProfileReceiverApplyAdmissionV1, - ) => { - expect(admission.validUntilUnixMs).toBe(validUntilUnixMs); - const remainingMs = admission.assertFreshAtApply(); - expect(remainingMs).toBe(60); - admittedDeadlineMs = Math.min( - existingMonotonicDeadlineMs, - monotonicNowMs + remainingMs, - ); + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => freshApply.admitFreshApply({ + existingMonotonicDeadlineMs, + monotonicNowMs, + }, (deadline) => { + admittedDeadlineMs = deadline; return { outcome: 'applied' as const, stateRevision: '6', appliedStateDigest: `0x${'8'.repeat(64)}`, }; - }); + })); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -387,6 +392,59 @@ describe('agent-profile system-record active receiver', () => { expect(admittedDeadlineMs).not.toBe(validUntilUnixMs); }); + it('allows the lifecycle bridge to enter fresh apply only once', async () => { + const fixture = await publishedFixture(); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + })); + const consumeCandidate = vi.fn(async ( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => { + const result = await freshApply.admitFreshApply( + DEFAULT_MONOTONIC_APPLY_TIMING, + apply, + ); + await expect(freshApply.admitFreshApply( + DEFAULT_MONOTONIC_APPLY_TIMING, + apply, + )).rejects.toThrow(/one-shot/); + return result; + }); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + consumeCandidate, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(apply).toHaveBeenCalledTimes(1); + }); + + it('does not acknowledge a bridge result returned outside fresh apply', async () => { + const fixture = await publishedFixture(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + consumeCandidate: vi.fn(async () => Object.freeze({ + outcome: 'applied', + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + }) as never), + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/did not return a fresh-apply result/); + }); + it('lets the lifecycle bridge reject expiry after its own asynchronous admission work', async () => { const fixture = await publishedFixture(); const validUntilMs = Date.parse(fixture.envelope.object.validUntil); @@ -394,27 +452,24 @@ describe('agent-profile system-record active receiver', () => { .mockReturnValueOnce(validUntilMs - 2) .mockReturnValueOnce(validUntilMs - 1) .mockReturnValue(validUntilMs); - let committed = false; + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + })); const consumeCandidate = vi.fn(async ( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - admission: AgentProfileReceiverApplyAdmissionV1, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, ) => { - expect(admission.validUntilUnixMs).toBe(validUntilMs); await Promise.resolve(); - admission.assertFreshAtApply(); - committed = true; - return { - outcome: 'applied' as const, - stateRevision: '6', - appliedStateDigest: `0x${'8'.repeat(64)}`, - }; + return freshApply.admitFreshApply(DEFAULT_MONOTONIC_APPLY_TIMING, apply); }); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -422,21 +477,21 @@ describe('agent-profile system-record active receiver', () => { .rejects.toThrow(/expired agent-profile head/); expect(nowMs).toHaveBeenCalledTimes(3); expect(consumeCandidate).toHaveBeenCalledTimes(1); - expect(committed).toBe(false); + expect(apply).not.toHaveBeenCalled(); }); it('hands every derived owned subject to the materializer candidate', async () => { const fixture = await publishedFixture(true); - const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ - outcome: 'applied' as const, - stateRevision: '1', - appliedStateDigest: `0x${'a'.repeat(64)}`, - })); + const consumeCandidate = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => admitFixtureApply(freshApply, '1', 'a')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -457,17 +512,17 @@ describe('agent-profile system-record active receiver', () => { it('traverses post-transition authority history and hands off its verified lineage', async () => { const fixture = await rotatedPublishedFixture(); const verifyAuthorityEnvelope = vi.fn(() => true); - const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ - outcome: 'applied' as const, - stateRevision: '5', - appliedStateDigest: `0x${'9'.repeat(64)}`, - })); + const consumeCandidate = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => admitFixtureApply(freshApply, '5', '9')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { resolve: fixture.resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyAuthorityEnvelope, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -517,7 +572,7 @@ describe('agent-profile system-record active receiver', () => { nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyAuthorityEnvelope: (candidate) => condition !== 'refused' || candidate.object.objectType !== 'authority-transition', - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -539,7 +594,7 @@ describe('agent-profile system-record active receiver', () => { : fixture.store.resolve(lookup, signal), }), nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -570,7 +625,7 @@ describe('agent-profile system-record active receiver', () => { }, }), nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -579,52 +634,19 @@ describe('agent-profile system-record active receiver', () => { expect(consumeCandidate).not.toHaveBeenCalled(); }); - it('fails closed when verified projection bytes do not bind the supplied bundle', async () => { - const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); - const receiver = createAgentProfileReceiverV1({ - networkId: NETWORK, - artifacts: fixture.store, - nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => { - const verified = verifiedFixtureBundle(bundleBytes); - return Object.freeze({ - ...verified, - canonicalProjectionBytes: Uint8Array.from([0]), - }); - }, - consumeCandidate, - }); - - await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) - .rejects.toThrow(/projection does not bind the supplied bundle/); - expect(consumeCandidate).not.toHaveBeenCalled(); - }); - - it('fails closed when verified projection quads do not bind their authenticated bytes', async () => { + it('fails closed when final bundle verification refuses the closure', async () => { const fixture = await publishedFixture(); const consumeCandidate = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => { - const verified = verifiedFixtureBundle(bundleBytes); - return Object.freeze({ - ...verified, - projectionQuads: Object.freeze([Object.freeze({ - subject: 'urn:unrelated:subject', - predicate: 'urn:unrelated:predicate', - object: '"unrelated"', - graph: '', - })]), - }); - }, + verifyCurrentBundle: () => false, consumeCandidate, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) - .rejects.toThrow(/projection quads do not bind the supplied bundle/); + .rejects.toThrow(/bundle verification failed/); expect(consumeCandidate).not.toHaveBeenCalled(); }); @@ -641,15 +663,14 @@ describe('agent-profile system-record active receiver', () => { decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, ); const verifyCurrentBundle = vi.fn((_head, bundleBytes: Uint8Array) => { - const verified = verifiedFixtureBundle(Uint8Array.from(bundleBytes)); bundleBytes.fill(0); - return verified; + return true; }); - const consumeCandidate = vi.fn(async (_candidate: AgentProfileReceiverCandidateV1) => ({ - outcome: 'applied' as const, - stateRevision: '4', - appliedStateDigest: `0x${'f'.repeat(64)}`, - })); + const consumeCandidate = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => admitFixtureApply(freshApply, '4', 'f')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, @@ -674,15 +695,18 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), - consumeCandidate: async () => { - controller.abort(new Error('late stop')); - return { - outcome: 'applied', - stateRevision: '2', - appliedStateDigest: `0x${'c'.repeat(64)}`, - }; - }, + verifyCurrentBundle: () => true, + consumeCandidate: (_candidate, _signal, freshApply) => freshApply.admitFreshApply( + DEFAULT_MONOTONIC_APPLY_TIMING, + () => { + controller.abort(new Error('late stop')); + return { + outcome: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + }; + }, + ), }); await expect(receiver.receiveActive(fixture.row, controller.signal)).resolves.toEqual({ @@ -742,7 +766,7 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate: vi.fn(), }); @@ -758,7 +782,7 @@ describe('agent-profile system-record active receiver', () => { const consumeCandidate = vi.fn(); const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); const verifyCurrentBundle = vi.fn( - (_head, bundleBytes: Uint8Array) => verifiedFixtureBundle(bundleBytes), + () => true, ); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, @@ -785,7 +809,7 @@ describe('agent-profile system-record active receiver', () => { artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyAuthorityEnvelope: () => false, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -820,7 +844,7 @@ describe('agent-profile system-record active receiver', () => { : fixture.store.resolve(lookup, signal), }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -853,7 +877,7 @@ describe('agent-profile system-record active receiver', () => { }, }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle: (_head, bundleBytes) => verifiedFixtureBundle(bundleBytes), + verifyCurrentBundle: () => true, consumeCandidate, }); @@ -867,13 +891,13 @@ describe('agent-profile system-record active receiver', () => { it('captures lifecycle dependencies once instead of rereading mutable options', async () => { const fixture = await publishedFixture(); const verifyCurrentBundle = vi.fn( - (_head, bundleBytes: Uint8Array) => verifiedFixtureBundle(bundleBytes), + () => true, ); - const consumeCandidate = vi.fn(async () => ({ - outcome: 'applied' as const, - stateRevision: '3', - appliedStateDigest: `0x${'e'.repeat(64)}`, - })); + const consumeCandidate = vi.fn(( + _candidate: AgentProfileReceiverCandidateV1, + _signal: AbortSignal, + freshApply: AgentProfileReceiverFreshApplyCapabilityV1, + ) => admitFixtureApply(freshApply, '3', 'e')); const resolveArtifact = vi.fn(fixture.store.resolve.bind(fixture.store)); const repository = { resolve: resolveArtifact }; const mutable = { From 572d3c459040c794a400b9a5e86106acba62ff62 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 10 Aug 2026 00:42:47 +0200 Subject: [PATCH 15/18] refactor(agent): phase verified profile apply --- .../agent/src/system-records/receiver-v1.ts | 201 +++----- .../agent-profile-receiver-v1-fixture.ts | 244 ++++++++++ .../test/system-record-receiver-v1.test.ts | 458 +++++++----------- packages/core/src/cg-shared-projection.ts | 167 ++++--- .../core/test/cg-shared-projection.test.ts | 51 ++ 5 files changed, 630 insertions(+), 491 deletions(-) create mode 100644 packages/agent/test/support/agent-profile-receiver-v1-fixture.ts diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 529e1707e1..5b0101ba95 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 import { + decodeCanonicalGraphlessProjectionV1, decodeOpaqueKaBundleV1, - tripleContentV10, } from '@origintrail-official/dkg-core'; import { buildAgentProfileVerificationClosureV1, @@ -39,7 +39,6 @@ import { type SystemRecordArtifactRepositoryV1, type SystemRecordArtifactV1, } from './artifact-v1.js'; -import { parseNQuads } from '../dkg-agent-utils.js'; export type SignedAgentProfileActiveHeadEnvelopeV1 = SignedAgentProfileHeadEnvelopeV1 & { readonly object: AgentProfileActiveHeadObjectV1; @@ -55,34 +54,18 @@ export interface AgentProfileReceiverCandidateV1 { readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; } -export interface AgentProfileReceiverMonotonicApplyTimingV1 { +export interface AgentProfileReceiverPreparedApplyV1 { /** Existing authenticated Storage deadline in the bridge's monotonic clock domain. */ readonly existingMonotonicDeadlineMs: number; /** `Math.floor(performance.now())` captured after bridge waits. */ readonly monotonicNowMs: number; -} - -const FRESH_APPLY_OUTCOME_V1: unique symbol = Symbol('agent-profile-fresh-apply-outcome-v1'); - -interface AgentProfileReceiverFreshApplyResultV1 { - readonly [FRESH_APPLY_OUTCOME_V1]: SystemRecordApplyOutcomeV1; -} - -/** Receiver-owned one-shot entry into lifecycle proof issuance and atomic apply. */ -export interface AgentProfileReceiverFreshApplyCapabilityV1 { /** - * After its preparation waits, the lifecycle bridge supplies its authenticated - * existing deadline and freshly captured monotonic time. The receiver checks - * signed wall-clock freshness, clamps the monotonic deadline, and immediately - * invokes apply. The callback receives no Unix timestamp and must begin proof - * issuance/apply admission with the supplied deadline. + * Begin lifecycle proof issuance and atomic apply synchronously with the + * receiver-admitted deadline. No Unix timestamp crosses this boundary. */ - readonly admitFreshApply: ( - timing: AgentProfileReceiverMonotonicApplyTimingV1, - apply: ( - admittedDeadlineMs: number, - ) => SystemRecordApplyOutcomeV1 | Promise, - ) => Promise; + readonly apply: ( + admittedDeadlineMs: number, + ) => SystemRecordApplyOutcomeV1 | Promise; } export interface CreateAgentProfileReceiverOptionsV1 { @@ -106,17 +89,16 @@ export interface CreateAgentProfileReceiverOptionsV1 { signal: AbortSignal, ) => boolean | Promise; /** - * Lifecycle-owned bridge into the storage runtime. It mints and consumes the - * private replacement proof inside one structured call; no proof escapes. - * Its return value must come from freshApply.admitFreshApply, so the receiver - * owns the final freshness check and monotonic-deadline clamp. + * Lifecycle-owned preparation for storage apply. After all asynchronous + * preparation, it returns authenticated monotonic timing and the apply entry. + * The receiver owns final freshness, deadline clamping, and the sole call to + * apply; no replacement proof or prior apply outcome crosses this boundary. */ - readonly consumeCandidate: ( + readonly prepareCandidateApply: ( input: AgentProfileReceiverCandidateV1, signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => AgentProfileReceiverFreshApplyResultV1 - | Promise; + ) => AgentProfileReceiverPreparedApplyV1 + | Promise; /** Unix wall-clock milliseconds, injectable for deterministic verification. */ readonly nowMs?: () => number; } @@ -142,7 +124,7 @@ export function createAgentProfileReceiverV1( const networkId = options.networkId; const resolveArtifact = options.artifacts.resolve.bind(options.artifacts); const verifyCurrentBundle = options.verifyCurrentBundle; - const consumeCandidate = options.consumeCandidate; + const prepareCandidateApply = options.prepareCandidateApply; const nowMs = options.nowMs; const verifyAuthorityEnvelope = options.verifyAuthorityEnvelope ?? ((envelope: SignedAgentProfileHeadEnvelopeV1 @@ -181,16 +163,29 @@ export function createAgentProfileReceiverV1( validUntilUnixMs, receiverNowMs(nowMs?.() ?? Date.now()), ); - const freshApply = createFreshApplyCapabilityV1({ + const prepared = await prepareCandidateApply(candidate, signal); + signal.throwIfAborted(); + const apply = readPreparedApplyV1(prepared); + signal.throwIfAborted(); + const remainingMs = assertActiveDeadlineFreshV1( validUntilUnixMs, - signal, - nowUnixMs: () => receiverNowMs(nowMs?.() ?? Date.now()), - }); - const result = await consumeCandidate(candidate, signal, freshApply); + receiverNowMs(nowMs?.() ?? Date.now()), + ); + const translatedDeadlineMs = apply.monotonicNowMs + remainingMs; + if (!Number.isSafeInteger(translatedDeadlineMs)) { + throw new Error('agent-profile translated apply deadline is invalid'); + } + const admittedDeadlineMs = Math.min( + apply.existingMonotonicDeadlineMs, + translatedDeadlineMs, + ); + if (admittedDeadlineMs <= apply.monotonicNowMs) { + throw new Error('agent-profile monotonic apply admission is expired'); + } // Atomic apply is the point of no return. A cancellation that arrives // after the storage closure returns must not hide a committed outcome and // make the caller retry it as if nothing happened. - return unwrapFreshApplyResultV1(result); + return await apply.invoke(admittedDeadlineMs); }, }); } @@ -267,9 +262,14 @@ async function buildVerifiedActiveCandidateFactsV1( ); const decodedBundle = decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes); const canonicalProjectionBytes = Uint8Array.from(decodedBundle.projectionBytes); - const projectionQuads = Object.freeze( - deriveCanonicalProjectionQuadsV1(canonicalProjectionBytes), - ); + const projectionQuads = Object.freeze(decodeCanonicalGraphlessProjectionV1( + canonicalProjectionBytes, + ).map(({ subject, predicate, object }) => Object.freeze({ + subject, + predicate, + object, + graph: '', + }))); const resolvedSubjectTableArtifact = await resolveArtifact({ type: 'object', objectKind: 'owned-subject-table', @@ -447,67 +447,24 @@ function requiredClosureArtifactV1( return artifact; } -interface CreateFreshApplyCapabilityOptionsV1 { - readonly validUntilUnixMs: number; - readonly signal: AbortSignal; - readonly nowUnixMs: () => number; -} - -function createFreshApplyCapabilityV1( - options: CreateFreshApplyCapabilityOptionsV1, -): AgentProfileReceiverFreshApplyCapabilityV1 { - const { validUntilUnixMs, signal, nowUnixMs } = options; - let used = false; - return Object.freeze({ - admitFreshApply: async ( - timing: AgentProfileReceiverMonotonicApplyTimingV1, - apply: ( - admittedDeadlineMs: number, - ) => SystemRecordApplyOutcomeV1 | Promise, - ): Promise => { - if (used) throw new Error('agent-profile fresh-apply capability is one-shot'); - used = true; - signal.throwIfAborted(); - if (timing === null || typeof timing !== 'object') { - throw new Error('agent-profile monotonic apply timing is invalid'); - } - const existingMonotonicDeadlineMs = monotonicApplyMsV1( - timing.existingMonotonicDeadlineMs, - 'existing deadline', - ); - const monotonicNowMs = monotonicApplyMsV1( - timing.monotonicNowMs, - 'current time', - ); - if (typeof apply !== 'function') { - throw new Error('agent-profile fresh apply callback is invalid'); - } - const remainingMs = assertActiveDeadlineFreshV1(validUntilUnixMs, nowUnixMs()); - const translatedDeadlineMs = monotonicNowMs + remainingMs; - if (!Number.isSafeInteger(translatedDeadlineMs)) { - throw new Error('agent-profile translated apply deadline is invalid'); - } - const admittedDeadlineMs = Math.min( - existingMonotonicDeadlineMs, - translatedDeadlineMs, - ); - if (admittedDeadlineMs <= monotonicNowMs) { - throw new Error('agent-profile monotonic apply admission is expired'); - } - const outcome = await apply(admittedDeadlineMs); - return Object.freeze({ [FRESH_APPLY_OUTCOME_V1]: outcome }); - }, - }); -} - -function unwrapFreshApplyResultV1( - value: AgentProfileReceiverFreshApplyResultV1, -): SystemRecordApplyOutcomeV1 { - if (value === null || typeof value !== 'object' - || !Object.prototype.hasOwnProperty.call(value, FRESH_APPLY_OUTCOME_V1)) { - throw new Error('lifecycle bridge did not return a fresh-apply result'); +function readPreparedApplyV1(value: AgentProfileReceiverPreparedApplyV1): { + readonly existingMonotonicDeadlineMs: number; + readonly monotonicNowMs: number; + readonly invoke: AgentProfileReceiverPreparedApplyV1['apply']; +} { + if (value === null || typeof value !== 'object') { + throw new Error('lifecycle bridge did not return prepared apply state'); + } + const existingMonotonicDeadlineMs = monotonicApplyMsV1( + value.existingMonotonicDeadlineMs, + 'existing deadline', + ); + const monotonicNowMs = monotonicApplyMsV1(value.monotonicNowMs, 'current time'); + const invoke = value.apply; + if (typeof invoke !== 'function') { + throw new Error('agent-profile prepared apply callback is invalid'); } - return value[FRESH_APPLY_OUTCOME_V1]; + return Object.freeze({ existingMonotonicDeadlineMs, monotonicNowMs, invoke }); } function monotonicApplyMsV1(value: number, label: string): number { @@ -529,46 +486,6 @@ function assertActiveDeadlineFreshV1(validUntilUnixMs: number, nowUnixMs: number return remainingMs; } -function deriveCanonicalProjectionQuadsV1( - canonicalProjectionBytes: Uint8Array, -): Readonly[] { - let projectionText: string; - try { - projectionText = new TextDecoder('utf-8', { fatal: true }).decode(canonicalProjectionBytes); - } catch { - throw new Error('profile bundle projection bytes are not valid UTF-8'); - } - const quads = parseNQuads(projectionText).map((quad) => Object.freeze({ - subject: quad.subject, - predicate: quad.predicate, - object: quad.object, - graph: quad.graph, - })); - const reconstructed = new Uint8Array( - quads.reduce((total, quad) => total + tripleContentV10( - quad.subject, - quad.predicate, - quad.object, - ).byteLength + 1, 0), - ); - let offset = 0; - for (const quad of quads) { - if (quad.graph !== '') { - throw new Error('profile bundle projection must be graphless'); - } - const line = tripleContentV10(quad.subject, quad.predicate, quad.object); - reconstructed.set(line, offset); - offset += line.byteLength; - reconstructed[offset] = 0x0a; - offset += 1; - } - if (reconstructed.byteLength !== canonicalProjectionBytes.byteLength - || reconstructed.some((byte, index) => byte !== canonicalProjectionBytes[index])) { - throw new Error('profile bundle projection bytes do not encode exact canonical quads'); - } - return quads; -} - function receiverNowMs(value: number): number { if (!Number.isSafeInteger(value) || value < 0) { throw new Error('agent-profile receiver clock returned an invalid value'); diff --git a/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts b/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts new file mode 100644 index 0000000000..25a6ee01e1 --- /dev/null +++ b/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts @@ -0,0 +1,244 @@ +import { vi } from 'vitest'; + +import { + decodeOpaqueKaBundleV1, + encodeOpaqueKaBundleV1, +} from '@origintrail-official/dkg-core'; +import { + computeSystemRecordStableKeyHashV1, + digestSystemRecordBytesV1, + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + type AgentProfileAuthorityTransitionV1, + type SystemRecordInventoryRowV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { ethers } from 'ethers'; + +import { createEvmPersonalMessageSignerV1 } from '../../src/evm-message-signer-v1.js'; +import { prepareAgentProfileV1 } from '../../src/profile.js'; +import type { SystemRecordArtifactLookupV1 } from '../../src/system-records/artifact-v1.js'; +import { createInMemoryAgentProfilePublicationStoreV1 } from '../../src/system-records/in-memory-agent-profile-publication-store-v1.js'; +import type { AgentProfileReceiverPreparedApplyV1 } from '../../src/system-records/receiver-v1.js'; +import { + createFixtureAgentProfileProducerV1, + DEPLOYMENT, + envelopeArtifact, + makePrepared, + NETWORK, + OTHER_PRIVATE_KEY, + produce, + producerFixture, + publicationFor, + signHeadEnvelope, + signTransitionEnvelope, +} from './agent-profile-producer-v1-fixture.js'; + +export const DEFAULT_MONOTONIC_APPLY_TIMING = Object.freeze({ + existingMonotonicDeadlineMs: 10_000, + monotonicNowMs: 1_000, +}); + +export function preparedFixtureApply( + stateRevision: string, + digestCharacter: string, +): AgentProfileReceiverPreparedApplyV1 { + return Object.freeze({ + ...DEFAULT_MONOTONIC_APPLY_TIMING, + apply: () => Object.freeze({ + outcome: 'applied' as const, + stateRevision, + appliedStateDigest: `0x${digestCharacter.repeat(64)}`, + }), + }); +} + +export async function publishedReceiverFixture(withDerivedSubjects = false) { + const fixture = await producerFixture(); + const prepared = withDerivedSubjects + ? prepareAgentProfileV1({ + peerId: fixture.peerSigner.peerId, + publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), + agentAddress: fixture.evmSigner.address, + name: 'Receiver multi-subject fixture', + nodeRole: 'edge', + lastSeen: '2026-08-07T12:00:00.000Z', + skills: [{ + skillType: 'GraphQuery', + pricePerCall: 1, + currency: 'TRAC', + successRate: 0.99, + pricingModel: 'PerInvocation', + }], + contextGraphsServed: ['receiver-test-graph'], + }) + : fixture.prepared; + const publication = withDerivedSubjects + ? await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z') + : fixture.publication; + const producer = createFixtureAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: fixture.peerSigner, + evmSigner: fixture.evmSigner, + store: fixture.store, + fence: () => undefined, + install: () => undefined, + }); + await produce(producer, prepared, publication); + const envelope = fixture.store.snapshot().currentHead; + if (envelope === null) throw new Error('fixture producer did not publish a head'); + const head = envelope.object; + const row: SystemRecordInventoryRowV1 = Object.freeze({ + stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), + peerId: head.peerId, + authoritySequence: head.authoritySequence, + version: head.version, + headDigest: envelope.objectDigest, + tombstone: false, + quarantined: false, + }); + return { ...fixture, prepared, publication, envelope, row }; +} + +export async function rotatedPublishedReceiverFixture() { + const prior = await publishedReceiverFixture(); + const nextSigner = createEvmPersonalMessageSignerV1({ + mode: 'custodial', + address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, + privateKey: OTHER_PRIVATE_KEY, + purpose: 'receiver post-transition test', + }); + const prepared = makePrepared( + prior.peerSigner, + nextSigner.address, + '2026-08-07T12:20:00.000Z', + ); + const publication = await publicationFor( + prepared, + nextSigner.address, + '2026-08-07T12:20:00Z', + OTHER_PRIVATE_KEY, + ); + const currentStore = createInMemoryAgentProfilePublicationStoreV1(); + await produce(createFixtureAgentProfileProducerV1({ + networkId: NETWORK, + publicationDeployment: DEPLOYMENT, + peerSigner: prior.peerSigner, + evmSigner: nextSigner, + store: currentStore, + fence: () => undefined, + install: () => undefined, + }), prepared, publication); + const bootstrapEnvelope = currentStore.snapshot().currentHead; + if (bootstrapEnvelope === null) throw new Error('rotated fixture did not publish a head'); + const transition: AgentProfileAuthorityTransitionV1 = Object.freeze({ + objectType: 'authority-transition', + kind: 'agents', + mode: 'co-signed', + networkId: NETWORK, + peerId: prior.peerSigner.peerId, + peerPublicKey: prior.peerSigner.publicKey, + priorAuthoritySequence: '0', + nextAuthoritySequence: '1', + priorHeadDigest: prior.envelope.objectDigest, + priorEvmIssuer: prior.evmSigner.address, + nextEvmIssuer: nextSigner.address, + nextRoot: prepared.rootEntity, + issuedAt: '2026-08-07T12:10:00Z', + }); + const transitionEnvelope = await signTransitionEnvelope( + transition, + prior.peerSigner, + prior.evmSigner, + nextSigner, + ); + const envelope = await signHeadEnvelope(Object.freeze({ + ...bootstrapEnvelope.object, + authoritySequence: '1', + acceptedTransitionDigest: transitionEnvelope.objectDigest, + }), prior.peerSigner, nextSigner); + const currentHeadArtifact = envelopeArtifact('agent-profile-head', envelope); + const transitionArtifact = envelopeArtifact('authority-transition', transitionEnvelope); + const priorHeadArtifact = envelopeArtifact('agent-profile-head', prior.envelope); + const resolve = vi.fn(async (lookup: SystemRecordArtifactLookupV1, signal: AbortSignal) => { + if (lookup.type === 'object') { + if (lookup.objectKind === currentHeadArtifact.objectKind + && lookup.objectDigest === currentHeadArtifact.objectDigest) return currentHeadArtifact; + if (lookup.objectKind === transitionArtifact.objectKind + && lookup.objectDigest === transitionArtifact.objectDigest) return transitionArtifact; + if (lookup.objectKind === priorHeadArtifact.objectKind + && lookup.objectDigest === priorHeadArtifact.objectDigest) return priorHeadArtifact; + } + return currentStore.resolve(lookup, signal); + }); + const head = envelope.object; + const row: SystemRecordInventoryRowV1 = Object.freeze({ + stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), + peerId: head.peerId, + authoritySequence: head.authoritySequence, + version: head.version, + headDigest: envelope.objectDigest, + tombstone: false, + quarantined: false, + }); + return { prior, prepared, envelope, transitionEnvelope, resolve, row }; +} + +export async function publishedReceiverFixtureWithProjectionBytes( + transform: (canonicalProjectionBytes: Uint8Array) => Uint8Array, +) { + const fixture = await publishedReceiverFixture(); + const originalBundle = await fixture.store.resolve({ + type: 'object', + objectKind: 'profile-bundle', + objectDigest: fixture.envelope.object.bundleDigest, + }, new AbortController().signal); + if (originalBundle === null) throw new Error('fixture bundle was not retained'); + const decoded = decodeOpaqueKaBundleV1(originalBundle.canonicalBytes); + const projectionBytes = transform(Uint8Array.from(decoded.projectionBytes)); + const encoded = encodeOpaqueKaBundleV1(projectionBytes, decoded.sealBytes); + const bundleDigest = digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + encoded.bundleBytes, + ); + const envelope = await signHeadEnvelope(Object.freeze({ + ...fixture.envelope.object, + bundleDigest, + projectionBytes: String(projectionBytes.byteLength), + }), fixture.peerSigner, fixture.evmSigner); + const headArtifact = envelopeArtifact('agent-profile-head', envelope); + const bundleArtifact = Object.freeze({ + objectKind: 'profile-bundle' as const, + objectDigest: bundleDigest, + canonicalBytes: encoded.bundleBytes, + }); + const resolve = vi.fn(async (lookup: SystemRecordArtifactLookupV1, signal: AbortSignal) => { + if (lookup.type === 'object') { + if (lookup.objectKind === headArtifact.objectKind + && lookup.objectDigest === headArtifact.objectDigest) return headArtifact; + if (lookup.objectKind === bundleArtifact.objectKind + && lookup.objectDigest === bundleArtifact.objectDigest) return bundleArtifact; + } + return fixture.store.resolve(lookup, signal); + }); + return Object.freeze({ + ...fixture, + envelope, + projectionBytes, + artifacts: Object.freeze({ resolve }), + row: Object.freeze({ ...fixture.row, headDigest: envelope.objectDigest }), + }); +} + +export function compareReceiverQuad( + left: { subject: string; predicate: string; object: string; graph: string }, + right: { subject: string; predicate: string; object: string; graph: string }, +): number { + return left.subject.localeCompare(right.subject) + || left.predicate.localeCompare(right.predicate) + || left.object.localeCompare(right.object) + || left.graph.localeCompare(right.graph); +} + +export function compareReceiverUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index a3f5a513d5..748bac8e14 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -4,188 +4,33 @@ import { decodeOpaqueKaBundleV1 } from '@origintrail-official/dkg-core'; import { canonicalizeOwnedSubjectTableObjectV1, - computeSystemRecordStableKeyHashV1, deriveAgentProfileOwnedSubjectV1, EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, SYSTEM_RECORD_OBJECT_CAPS_V1, - type AgentProfileAuthorityTransitionV1, type AgentProfileHeadObjectV1, type OwnedSubjectTableObjectV1, type SystemRecordInventoryRowV1, } from '@origintrail-official/dkg-core/system-record-v1'; -import { ethers } from 'ethers'; -import { createEvmPersonalMessageSignerV1 } from '../src/evm-message-signer-v1.js'; -import { prepareAgentProfileV1 } from '../src/profile.js'; -import { createInMemoryAgentProfilePublicationStoreV1 } from '../src/system-records/in-memory-agent-profile-publication-store-v1.js'; import { createAgentProfileReceiverV1, type AgentProfileReceiverCandidateV1, - type AgentProfileReceiverFreshApplyCapabilityV1, } from '../src/system-records/receiver-v1.js'; import { - createFixtureAgentProfileProducerV1, - DEPLOYMENT, envelopeArtifact, - makePrepared, NETWORK, - OTHER_PRIVATE_KEY, - produce, - producerFixture, PRODUCER_FIXTURE_NOW_MS, - publicationFor, signHeadEnvelope, - signTransitionEnvelope, } from './support/agent-profile-producer-v1-fixture.js'; - -async function publishedFixture(withDerivedSubjects = false) { - const fixture = await producerFixture(); - const prepared = withDerivedSubjects - ? prepareAgentProfileV1({ - peerId: fixture.peerSigner.peerId, - publicKey: Buffer.from(fixture.peerSigner.publicKey, 'base64url').toString('base64'), - agentAddress: fixture.evmSigner.address, - name: 'Receiver multi-subject fixture', - nodeRole: 'edge', - lastSeen: '2026-08-07T12:00:00.000Z', - skills: [{ - skillType: 'GraphQuery', - pricePerCall: 1, - currency: 'TRAC', - successRate: 0.99, - pricingModel: 'PerInvocation', - }], - contextGraphsServed: ['receiver-test-graph'], - }) - : fixture.prepared; - const publication = withDerivedSubjects - ? await publicationFor(prepared, fixture.evmSigner.address, '2026-08-07T12:00:00Z') - : fixture.publication; - const producer = createFixtureAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: fixture.peerSigner, - evmSigner: fixture.evmSigner, - store: fixture.store, - fence: () => undefined, - install: () => undefined, - }); - await produce(producer, prepared, publication); - const envelope = fixture.store.snapshot().currentHead; - if (envelope === null) throw new Error('fixture producer did not publish a head'); - const head = envelope.object; - const row: SystemRecordInventoryRowV1 = Object.freeze({ - stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), - peerId: head.peerId, - authoritySequence: head.authoritySequence, - version: head.version, - headDigest: envelope.objectDigest, - tombstone: false, - quarantined: false, - }); - return { ...fixture, prepared, publication, envelope, row }; -} - -async function rotatedPublishedFixture() { - const prior = await publishedFixture(); - const nextSigner = createEvmPersonalMessageSignerV1({ - mode: 'custodial', - address: new ethers.Wallet(OTHER_PRIVATE_KEY).address, - privateKey: OTHER_PRIVATE_KEY, - purpose: 'receiver post-transition test', - }); - const prepared = makePrepared( - prior.peerSigner, - nextSigner.address, - '2026-08-07T12:20:00.000Z', - ); - const publication = await publicationFor( - prepared, - nextSigner.address, - '2026-08-07T12:20:00Z', - OTHER_PRIVATE_KEY, - ); - const currentStore = createInMemoryAgentProfilePublicationStoreV1(); - await produce(createFixtureAgentProfileProducerV1({ - networkId: NETWORK, - publicationDeployment: DEPLOYMENT, - peerSigner: prior.peerSigner, - evmSigner: nextSigner, - store: currentStore, - fence: () => undefined, - install: () => undefined, - }), prepared, publication); - const bootstrapEnvelope = currentStore.snapshot().currentHead; - if (bootstrapEnvelope === null) throw new Error('rotated fixture did not publish a head'); - const transition: AgentProfileAuthorityTransitionV1 = Object.freeze({ - objectType: 'authority-transition', - kind: 'agents', - mode: 'co-signed', - networkId: NETWORK, - peerId: prior.peerSigner.peerId, - peerPublicKey: prior.peerSigner.publicKey, - priorAuthoritySequence: '0', - nextAuthoritySequence: '1', - priorHeadDigest: prior.envelope.objectDigest, - priorEvmIssuer: prior.evmSigner.address, - nextEvmIssuer: nextSigner.address, - nextRoot: prepared.rootEntity, - issuedAt: '2026-08-07T12:10:00Z', - }); - const transitionEnvelope = await signTransitionEnvelope( - transition, - prior.peerSigner, - prior.evmSigner, - nextSigner, - ); - const envelope = await signHeadEnvelope(Object.freeze({ - ...bootstrapEnvelope.object, - authoritySequence: '1', - acceptedTransitionDigest: transitionEnvelope.objectDigest, - }), prior.peerSigner, nextSigner); - const currentHeadArtifact = envelopeArtifact('agent-profile-head', envelope); - const transitionArtifact = envelopeArtifact('authority-transition', transitionEnvelope); - const priorHeadArtifact = envelopeArtifact('agent-profile-head', prior.envelope); - const resolve = vi.fn(async (lookup, signal) => { - if (lookup.type === 'object') { - if (lookup.objectKind === currentHeadArtifact.objectKind - && lookup.objectDigest === currentHeadArtifact.objectDigest) return currentHeadArtifact; - if (lookup.objectKind === transitionArtifact.objectKind - && lookup.objectDigest === transitionArtifact.objectDigest) return transitionArtifact; - if (lookup.objectKind === priorHeadArtifact.objectKind - && lookup.objectDigest === priorHeadArtifact.objectDigest) return priorHeadArtifact; - } - return currentStore.resolve(lookup, signal); - }); - const head = envelope.object; - const row: SystemRecordInventoryRowV1 = Object.freeze({ - stableKeyHash: computeSystemRecordStableKeyHashV1(head.networkId, head.peerId), - peerId: head.peerId, - authoritySequence: head.authoritySequence, - version: head.version, - headDigest: envelope.objectDigest, - tombstone: false, - quarantined: false, - }); - return { prior, prepared, envelope, transitionEnvelope, resolve, row }; -} - -const DEFAULT_MONOTONIC_APPLY_TIMING = Object.freeze({ - existingMonotonicDeadlineMs: 10_000, - monotonicNowMs: 1_000, -}); - -function admitFixtureApply( - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - stateRevision: string, - digestCharacter: string, -) { - return freshApply.admitFreshApply(DEFAULT_MONOTONIC_APPLY_TIMING, () => ({ - outcome: 'applied', - stateRevision, - appliedStateDigest: `0x${digestCharacter.repeat(64)}`, - })); -} +import { + compareReceiverQuad as compareQuad, + compareReceiverUtf8 as compareUtf8, + DEFAULT_MONOTONIC_APPLY_TIMING, + preparedFixtureApply, + publishedReceiverFixture as publishedFixture, + publishedReceiverFixtureWithProjectionBytes, + rotatedPublishedReceiverFixture as rotatedPublishedFixture, +} from './support/agent-profile-receiver-v1-fixture.js'; describe('agent-profile system-record active receiver', () => { it('verifies the exact closure and submits one immutable active candidate', async () => { @@ -203,24 +48,23 @@ describe('agent-profile system-record active receiver', () => { expect(receivedSignal).toBe(signal); return true; }); - const consumeCandidate = vi.fn(( + const prepareCandidateApply = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => admitFixtureApply(freshApply, '1', 'a')); + ) => preparedFixtureApply('1', 'a')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, signal)) .resolves.toMatchObject({ outcome: 'applied' }); expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); - expect(consumeCandidate).toHaveBeenCalledTimes(1); - const candidate = consumeCandidate.mock.calls[0]![0]; + expect(prepareCandidateApply).toHaveBeenCalledTimes(1); + const candidate = prepareCandidateApply.mock.calls[0]![0]; expect(candidate.head).toEqual(fixture.envelope.object); expect(candidate.envelope).toEqual(fixture.envelope); expect([...candidate.projectionQuads].sort(compareQuad)) @@ -230,12 +74,8 @@ describe('agent-profile system-record active receiver', () => { decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes).projectionBytes, ); expect(candidate).not.toHaveProperty('signal'); - expect(consumeCandidate.mock.calls[0]![1]).toBe(signal); - expect(consumeCandidate.mock.calls[0]![2]).toMatchObject({ - admitFreshApply: expect.any(Function), - }); - expect(consumeCandidate.mock.calls[0]![2]).not.toHaveProperty('validUntilUnixMs'); - expect(Object.isFrozen(consumeCandidate.mock.calls[0]![2])).toBe(true); + expect(prepareCandidateApply.mock.calls[0]![1]).toBe(signal); + expect(prepareCandidateApply.mock.calls[0]).toHaveLength(2); }); it('does not invoke active bundle verification for a non-active current head', async () => { @@ -272,13 +112,13 @@ describe('agent-profile system-record active receiver', () => { ? tombstoneArtifact : fixture.store.resolve(lookup, signal)); const verifyCurrentBundle = vi.fn(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(Object.freeze({ @@ -288,7 +128,7 @@ describe('agent-profile system-record active receiver', () => { }), new AbortController().signal)).rejects.toThrow(/inventory row does not bind/); expect(resolve).toHaveBeenCalledTimes(1); expect(verifyCurrentBundle).not.toHaveBeenCalled(); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('rejects an expired active head before bundle verification or materialization', async () => { @@ -309,13 +149,13 @@ describe('agent-profile system-record active receiver', () => { ? expiredArtifact : fixture.store.resolve(lookup, signal)); const verifyCurrentBundle = vi.fn(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(Object.freeze({ @@ -324,7 +164,7 @@ describe('agent-profile system-record active receiver', () => { }), new AbortController().signal)).rejects.toThrow(/expired agent-profile head/); expect(resolve).toHaveBeenCalledTimes(1); expect(verifyCurrentBundle).not.toHaveBeenCalled(); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('rechecks freshness immediately before the materialization point of no return', async () => { @@ -336,20 +176,20 @@ describe('agent-profile system-record active receiver', () => { const verifyCurrentBundle = vi.fn( () => true, ); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(/expired agent-profile head/); expect(nowMs).toHaveBeenCalledTimes(2); expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('clamps signed wall-clock expiry onto the bridge monotonic deadline', async () => { @@ -362,27 +202,27 @@ describe('agent-profile system-record active receiver', () => { const existingMonotonicDeadlineMs = 5_200; const monotonicNowMs = 5_000; let admittedDeadlineMs: number | undefined; - const consumeCandidate = vi.fn(( + const prepareCandidateApply = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => freshApply.admitFreshApply({ + ) => Object.freeze({ existingMonotonicDeadlineMs, monotonicNowMs, - }, (deadline) => { - admittedDeadlineMs = deadline; - return { - outcome: 'applied' as const, - stateRevision: '6', - appliedStateDigest: `0x${'8'.repeat(64)}`, - }; + apply: (deadline: number) => { + admittedDeadlineMs = deadline; + return { + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + }; + }, })); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) @@ -392,34 +232,23 @@ describe('agent-profile system-record active receiver', () => { expect(admittedDeadlineMs).not.toBe(validUntilUnixMs); }); - it('allows the lifecycle bridge to enter fresh apply only once', async () => { + it('invokes the prepared lifecycle apply entry exactly once', async () => { const fixture = await publishedFixture(); const apply = vi.fn(() => ({ outcome: 'applied' as const, stateRevision: '6', appliedStateDigest: `0x${'8'.repeat(64)}`, })); - const consumeCandidate = vi.fn(async ( - _candidate: AgentProfileReceiverCandidateV1, - _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => { - const result = await freshApply.admitFreshApply( - DEFAULT_MONOTONIC_APPLY_TIMING, - apply, - ); - await expect(freshApply.admitFreshApply( - DEFAULT_MONOTONIC_APPLY_TIMING, - apply, - )).rejects.toThrow(/one-shot/); - return result; - }); + const prepareCandidateApply = vi.fn(() => Object.freeze({ + ...DEFAULT_MONOTONIC_APPLY_TIMING, + apply, + })); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) @@ -427,14 +256,14 @@ describe('agent-profile system-record active receiver', () => { expect(apply).toHaveBeenCalledTimes(1); }); - it('does not acknowledge a bridge result returned outside fresh apply', async () => { + it('does not acknowledge an apply outcome returned instead of prepared state', async () => { const fixture = await publishedFixture(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate: vi.fn(async () => Object.freeze({ + prepareCandidateApply: vi.fn(async () => Object.freeze({ outcome: 'applied', stateRevision: '6', appliedStateDigest: `0x${'8'.repeat(64)}`, @@ -442,7 +271,7 @@ describe('agent-profile system-record active receiver', () => { }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) - .rejects.toThrow(/did not return a fresh-apply result/); + .rejects.toThrow(/monotonic apply existing deadline is invalid/); }); it('lets the lifecycle bridge reject expiry after its own asynchronous admission work', async () => { @@ -457,49 +286,47 @@ describe('agent-profile system-record active receiver', () => { stateRevision: '6', appliedStateDigest: `0x${'8'.repeat(64)}`, })); - const consumeCandidate = vi.fn(async ( + const prepareCandidateApply = vi.fn(async ( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, ) => { await Promise.resolve(); - return freshApply.admitFreshApply(DEFAULT_MONOTONIC_APPLY_TIMING, apply); + return Object.freeze({ ...DEFAULT_MONOTONIC_APPLY_TIMING, apply }); }); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(/expired agent-profile head/); expect(nowMs).toHaveBeenCalledTimes(3); - expect(consumeCandidate).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).toHaveBeenCalledTimes(1); expect(apply).not.toHaveBeenCalled(); }); it('hands every derived owned subject to the materializer candidate', async () => { const fixture = await publishedFixture(true); - const consumeCandidate = vi.fn(( + const prepareCandidateApply = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => admitFixtureApply(freshApply, '1', 'a')); + ) => preparedFixtureApply('1', 'a')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive( fixture.row, new AbortController().signal, )).resolves.toMatchObject({ outcome: 'applied' }); - const candidate = consumeCandidate.mock.calls[0]![0]; + const candidate = prepareCandidateApply.mock.calls[0]![0]; const expectedOwnedSubjects = [...new Set( fixture.prepared.projectionQuads.map(({ subject }) => subject), )].sort(compareUtf8); @@ -512,18 +339,17 @@ describe('agent-profile system-record active receiver', () => { it('traverses post-transition authority history and hands off its verified lineage', async () => { const fixture = await rotatedPublishedFixture(); const verifyAuthorityEnvelope = vi.fn(() => true); - const consumeCandidate = vi.fn(( + const prepareCandidateApply = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => admitFixtureApply(freshApply, '5', '9')); + ) => preparedFixtureApply('5', '9')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { resolve: fixture.resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyAuthorityEnvelope, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) @@ -542,7 +368,7 @@ describe('agent-profile system-record active receiver', () => { 'authority-transition', 'agent-profile-head', ]); - const candidate = consumeCandidate.mock.calls[0]![0]; + const candidate = prepareCandidateApply.mock.calls[0]![0]; expect(candidate.verifiedAuthoritySummary).toMatchObject({ candidateHeadDigest: fixture.envelope.objectDigest, transitionLineage: [{ @@ -559,7 +385,7 @@ describe('agent-profile system-record active receiver', () => { 'fails closed when post-transition authority evidence is $condition', async (condition) => { const fixture = await rotatedPublishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { @@ -573,18 +399,18 @@ describe('agent-profile system-record active receiver', () => { verifyAuthorityEnvelope: (candidate) => condition !== 'refused' || candidate.object.objectType !== 'authority-transition', verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(condition === 'missing' ? /missing/ : /authority-transition verification/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }, ); it('fails closed when the exact owned-subject table is unavailable', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: Object.freeze({ @@ -595,17 +421,17 @@ describe('agent-profile system-record active receiver', () => { }), nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(/owned-subject table/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('fails closed when the owned-subject table bytes do not bind the verified head', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const alteredTable = Object.freeze([ fixture.envelope.object.rootSubject, deriveAgentProfileOwnedSubjectV1(fixture.envelope.object.rootSubject, 'capability', 1), @@ -626,28 +452,79 @@ describe('agent-profile system-record active receiver', () => { }), nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(/does not bind the verified head/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('fails closed when final bundle verification refuses the closure', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => false, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(/bundle verification failed/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'graphful projection', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + new TextDecoder().decode(projectionBytes).replace( + ' .\n', + ' .\n', + ), + ), + error: /projection-iri/, + }, + { + label: 'noncanonical projection order', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + `${new TextDecoder().decode(projectionBytes) + .split('\n').filter(Boolean).reverse().join('\n')}\n`, + ), + error: /projection-order/, + }, + { + label: 'invalid UTF-8 projection', + transform: (projectionBytes: Uint8Array) => { + const altered = Uint8Array.from(projectionBytes); + const literalStart = altered.indexOf(0x22); + if (literalStart < 0) throw new Error('fixture projection has no literal'); + altered[literalStart + 1] = 0xff; + return altered; + }, + error: /projection-utf8/, + }, + ])('rejects a signed $label after boolean bundle verification', async ({ + transform, + error, + }) => { + const fixture = await publishedReceiverFixtureWithProjectionBytes(transform); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('isolates signed bundle bytes from mutations by the injected verifier', async () => { @@ -666,23 +543,22 @@ describe('agent-profile system-record active receiver', () => { bundleBytes.fill(0); return true; }); - const consumeCandidate = vi.fn(( + const prepareCandidateApply = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => admitFixtureApply(freshApply, '4', 'f')); + ) => preparedFixtureApply('4', 'f')); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, signal)) .resolves.toMatchObject({ outcome: 'applied' }); expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); - const candidate = consumeCandidate.mock.calls[0]![0]; + const candidate = prepareCandidateApply.mock.calls[0]![0]; expect(candidate.canonicalProjectionBytes).toEqual(expectedProjectionBytes); expect([...candidate.projectionQuads].sort(compareQuad)) .toEqual([...fixture.prepared.projectionQuads].sort(compareQuad)); @@ -696,9 +572,9 @@ describe('agent-profile system-record active receiver', () => { artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate: (_candidate, _signal, freshApply) => freshApply.admitFreshApply( - DEFAULT_MONOTONIC_APPLY_TIMING, - () => { + prepareCandidateApply: () => Object.freeze({ + ...DEFAULT_MONOTONIC_APPLY_TIMING, + apply: () => { controller.abort(new Error('late stop')); return { outcome: 'applied', @@ -706,7 +582,7 @@ describe('agent-profile system-record active receiver', () => { appliedStateDigest: `0x${'c'.repeat(64)}`, }; }, - ), + }), }); await expect(receiver.receiveActive(fixture.row, controller.signal)).resolves.toEqual({ @@ -716,6 +592,31 @@ describe('agent-profile system-record active receiver', () => { }); }); + it('honors cancellation raised during lifecycle apply preparation', async () => { + const fixture = await publishedFixture(); + const controller = new AbortController(); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '2', + appliedStateDigest: `0x${'c'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: async () => { + await Promise.resolve(); + controller.abort(new Error('pre-apply stop')); + return Object.freeze({ ...DEFAULT_MONOTONIC_APPLY_TIMING, apply }); + }, + }); + + await expect(receiver.receiveActive(fixture.row, controller.signal)) + .rejects.toThrow('pre-apply stop'); + expect(apply).not.toHaveBeenCalled(); + }); + it('honors a caller abort before resolving any artifact', async () => { const resolve = vi.fn(); const controller = new AbortController(); @@ -724,7 +625,7 @@ describe('agent-profile system-record active receiver', () => { networkId: NETWORK, artifacts: { resolve }, verifyCurrentBundle: vi.fn(), - consumeCandidate: vi.fn(), + prepareCandidateApply: vi.fn(), }); const row: SystemRecordInventoryRowV1 = { stableKeyHash: `0x${'a'.repeat(64)}`, @@ -767,7 +668,7 @@ describe('agent-profile system-record active receiver', () => { artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate: vi.fn(), + prepareCandidateApply: vi.fn(), }); await expect(receiver.receiveActive( @@ -779,7 +680,7 @@ describe('agent-profile system-record active receiver', () => { it('fails closed when the verified head does not bind the inventory version', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const resolve = vi.fn(fixture.store.resolve.bind(fixture.store)); const verifyCurrentBundle = vi.fn( () => true, @@ -789,35 +690,35 @@ describe('agent-profile system-record active receiver', () => { artifacts: { resolve }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive( Object.freeze({ ...fixture.row, version: '1' }), new AbortController().signal, )).rejects.toThrow(/inventory row does not bind/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); expect(resolve).toHaveBeenCalledTimes(1); expect(verifyCurrentBundle).not.toHaveBeenCalled(); }); it('fails closed when final authority verification refuses the closure', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: fixture.store, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyAuthorityEnvelope: () => false, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive( fixture.row, new AbortController().signal, )).rejects.toThrow(/authority verification failed/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('fails closed when the default authority verifier sees a corrupted head signature', async () => { @@ -833,7 +734,7 @@ describe('agent-profile system-record active receiver', () => { : entry)), }) as typeof fixture.envelope; const corruptedArtifact = envelopeArtifact('agent-profile-head', corruptedEnvelope); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); const receiver = createAgentProfileReceiverV1({ networkId: NETWORK, artifacts: { @@ -845,17 +746,17 @@ describe('agent-profile system-record active receiver', () => { }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) .rejects.toThrow(/authority verification failed/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('rejects an oversized artifact before invoking typed-array copy hooks', async () => { const fixture = await publishedFixture(); - const consumeCandidate = vi.fn(); + const prepareCandidateApply = vi.fn(); class CopyTrapBytes extends Uint8Array { override *[Symbol.iterator](): ArrayIterator { throw new Error('unbounded artifact copy ran before the cap'); @@ -878,14 +779,14 @@ describe('agent-profile system-record active receiver', () => { }, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle: () => true, - consumeCandidate, + prepareCandidateApply, }); await expect(receiver.receiveActive( fixture.row, new AbortController().signal, )).rejects.toThrow(/closure artifact exceeds/); - expect(consumeCandidate).not.toHaveBeenCalled(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }); it('captures lifecycle dependencies once instead of rereading mutable options', async () => { @@ -893,11 +794,10 @@ describe('agent-profile system-record active receiver', () => { const verifyCurrentBundle = vi.fn( () => true, ); - const consumeCandidate = vi.fn(( + const prepareCandidateApply = vi.fn(( _candidate: AgentProfileReceiverCandidateV1, _signal: AbortSignal, - freshApply: AgentProfileReceiverFreshApplyCapabilityV1, - ) => admitFixtureApply(freshApply, '3', 'e')); + ) => preparedFixtureApply('3', 'e')); const resolveArtifact = vi.fn(fixture.store.resolve.bind(fixture.store)); const repository = { resolve: resolveArtifact }; const mutable = { @@ -905,13 +805,13 @@ describe('agent-profile system-record active receiver', () => { artifacts: repository, nowMs: () => PRODUCER_FIXTURE_NOW_MS, verifyCurrentBundle, - consumeCandidate, + prepareCandidateApply, }; const receiver = createAgentProfileReceiverV1(mutable); mutable.verifyCurrentBundle = vi.fn(() => { throw new Error('mutated verifier was observed'); }); - mutable.consumeCandidate = vi.fn(() => { + mutable.prepareCandidateApply = vi.fn(() => { throw new Error('mutated materializer was observed'); }); repository.resolve = vi.fn(() => { @@ -923,21 +823,7 @@ describe('agent-profile system-record active receiver', () => { new AbortController().signal, )).resolves.toMatchObject({ outcome: 'applied', stateRevision: '3' }); expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); - expect(consumeCandidate).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).toHaveBeenCalledTimes(1); expect(resolveArtifact).toHaveBeenCalled(); }); }); - -function compareQuad( - left: { subject: string; predicate: string; object: string; graph: string }, - right: { subject: string; predicate: string; object: string; graph: string }, -): number { - return left.subject.localeCompare(right.subject) - || left.predicate.localeCompare(right.predicate) - || left.object.localeCompare(right.object) - || left.graph.localeCompare(right.graph); -} - -function compareUtf8(left: string, right: string): number { - return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); -} diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index f8bde6f78c..367df4ec4f 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -77,6 +77,13 @@ export interface CgSharedPublicRootProjectionTripleV1 { readonly object: string; } +/** A graphless triple decoded from exact canonical V10 projection bytes. */ +export interface CanonicalGraphlessProjectionTripleV1 { + readonly subject: string; + readonly predicate: string; + readonly object: string; +} + /** * Encode public-root triples into the exact canonical bytes consumed by the * cg-shared-v1 verifier: V10-canonical terms, one LF per line, and raw UTF-8 @@ -103,6 +110,26 @@ export function encodeCanonicalCgSharedPublicRootProjectionV1( return projection; } +/** + * Decode exact canonical V10 projection bytes under the same bounded rules as + * cg-shared-v1 verification. The wire representation has no graph component. + */ +export function decodeCanonicalGraphlessProjectionV1( + projectionBytes: Uint8Array, + limits: CgSharedProjectionVerificationLimitsV1 = + DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, +): readonly Readonly[] { + const triples = decodeCanonicalProjectionTriplesV1( + projectionBytes, + normalizeVerificationLimits(limits), + ); + return Object.freeze(triples.map(({ subject, predicate, object }) => Object.freeze({ + subject, + predicate, + object: object.startsWith('<') ? object.slice(1, -1) : object, + }))); +} + /** * Process-local proof that one structurally verified transferred bundle carries * the exact canonical `cg-shared-v1` projection committed by its author seal. @@ -407,63 +434,22 @@ function verifyCanonicalProjectionBytes( readonly privateDataHash: Digest32V1; readonly assertionMerkleRoot: Digest32V1; } { - if (projectionBytes.byteLength === 0) { - fail('projection-empty', 'cg-shared-v1 projection must not be empty'); - } - if ( - projectionBytes.byteLength >= 3 - && projectionBytes[0] === 0xef - && projectionBytes[1] === 0xbb - && projectionBytes[2] === 0xbf - ) { - fail('projection-utf8', 'cg-shared-v1 projection must not start with a UTF-8 BOM'); - } - if (projectionBytes[projectionBytes.byteLength - 1] !== 0x0a) { - fail('projection-line-ending', 'cg-shared-v1 projection must end with one LF'); - } - const leaves: Uint8Array[] = []; let anchor: CanonicalProjectionTripleV1 | undefined; let privateHash: CanonicalProjectionTripleV1 | undefined; - let previousLine: Uint8Array | undefined; - let lineStart = 0; - let lineNumber = 0; + const triples = decodeCanonicalProjectionTriplesV1(projectionBytes, limits); const signedPublicTripleCount = BigInt(seal.publicTripleCount); + if (BigInt(triples.length) !== signedPublicTripleCount) { + fail( + 'projection-public-count', + 'canonical projection line count differs from seal publicTripleCount', + ); + } const reservedCommitmentSubject = `${seal.kaUal}${CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1}`; - for (let cursor = 0; cursor < projectionBytes.byteLength; cursor += 1) { - const byte = projectionBytes[cursor]; - if (byte === 0x0d) { - fail('projection-line-ending', 'raw CR is forbidden in cg-shared-v1 bytes'); - } - if (byte !== 0x0a) continue; - const line = projectionBytes.subarray(lineStart, cursor); - lineNumber += 1; - if (BigInt(lineNumber) > signedPublicTripleCount) { - fail( - 'projection-public-count', - 'projection contains more lines than the signed publicTripleCount', - ); - } - if (line.byteLength === 0) { - fail('projection-line', `projection line ${lineNumber} is empty`); - } - if (line.byteLength > limits.maxLineBytes) { - fail( - 'projection-resource-refused', - `projection line ${lineNumber} exceeds the local in-memory line limit`, - ); - } - if (previousLine !== undefined) { - const ordering = compareBytes(previousLine, line); - if (ordering === 0) { - fail('projection-duplicate', `projection line ${lineNumber} duplicates its predecessor`); - } - if (ordering > 0) { - fail('projection-order', `projection line ${lineNumber} is not in raw UTF-8 order`); - } - } - const triple = parseCanonicalProjectionLine(line, lineNumber); + for (let index = 0; index < triples.length; index += 1) { + const triple = triples[index]; + const lineNumber = index + 1; if ( triple.subject === reservedCommitmentSubject && triple.predicate !== CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 @@ -491,18 +477,6 @@ function verifyCanonicalProjectionBytes( ); } leaves.push(triple.leaf); - previousLine = line; - lineStart = cursor + 1; - } - if (lineStart !== projectionBytes.byteLength) { - fail('projection-line-ending', 'projection contains bytes after its final complete line'); - } - - if (BigInt(lineNumber) !== BigInt(seal.publicTripleCount)) { - fail( - 'projection-public-count', - 'canonical projection line count differs from seal publicTripleCount', - ); } assertPrivateCommitment(anchor, privateHash, seal); @@ -522,6 +496,73 @@ function verifyCanonicalProjectionBytes( return Object.freeze({ publicRoot, privateDataHash, assertionMerkleRoot }); } +function decodeCanonicalProjectionTriplesV1( + projectionBytes: Uint8Array, + limits: Readonly, +): readonly CanonicalProjectionTripleV1[] { + if (!(projectionBytes instanceof Uint8Array)) { + fail('projection-input', 'canonical projection bytes must be a Uint8Array'); + } + if (projectionBytes.byteLength === 0) { + fail('projection-empty', 'canonical projection must not be empty'); + } + if (projectionBytes.byteLength > limits.maxProjectionBytes) { + fail('projection-resource-refused', 'projection exceeds the local in-memory byte limit'); + } + if ( + projectionBytes.byteLength >= 3 + && projectionBytes[0] === 0xef + && projectionBytes[1] === 0xbb + && projectionBytes[2] === 0xbf + ) { + fail('projection-utf8', 'canonical projection must not start with a UTF-8 BOM'); + } + if (projectionBytes[projectionBytes.byteLength - 1] !== 0x0a) { + fail('projection-line-ending', 'canonical projection must end with one LF'); + } + + const triples: CanonicalProjectionTripleV1[] = []; + let previousLine: Uint8Array | undefined; + let lineStart = 0; + for (let cursor = 0; cursor < projectionBytes.byteLength; cursor += 1) { + const byte = projectionBytes[cursor]; + if (byte === 0x0d) { + fail('projection-line-ending', 'raw CR is forbidden in canonical projection bytes'); + } + if (byte !== 0x0a) continue; + const line = projectionBytes.subarray(lineStart, cursor); + const lineNumber = triples.length + 1; + if (lineNumber > limits.maxPublicTriples) { + fail('projection-resource-refused', 'projection exceeds the local in-memory triple limit'); + } + if (line.byteLength === 0) { + fail('projection-line', `projection line ${lineNumber} is empty`); + } + if (line.byteLength > limits.maxLineBytes) { + fail( + 'projection-resource-refused', + `projection line ${lineNumber} exceeds the local in-memory line limit`, + ); + } + if (previousLine !== undefined) { + const ordering = compareBytes(previousLine, line); + if (ordering === 0) { + fail('projection-duplicate', `projection line ${lineNumber} duplicates its predecessor`); + } + if (ordering > 0) { + fail('projection-order', `projection line ${lineNumber} is not in raw UTF-8 order`); + } + } + triples.push(parseCanonicalProjectionLine(line, lineNumber)); + previousLine = line; + lineStart = cursor + 1; + } + if (lineStart !== projectionBytes.byteLength) { + fail('projection-line-ending', 'projection contains bytes after its final complete line'); + } + return Object.freeze(triples); +} + function assertPrivateCommitment( anchor: CanonicalProjectionTripleV1 | undefined, privateHash: CanonicalProjectionTripleV1 | undefined, diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts index f2b4abae18..61ea4a0be8 100644 --- a/packages/core/test/cg-shared-projection.test.ts +++ b/packages/core/test/cg-shared-projection.test.ts @@ -17,6 +17,7 @@ import { CgSharedProjectionError, assertVerifiedCgSharedProjectionForTransferV1, assertVerifiedCgSharedProjectionV1, + decodeCanonicalGraphlessProjectionV1, readVerifiedCgSharedProjectionBytesV1, readVerifiedCgSharedProjectionMetadataV1, readVerifiedCgSharedProjectionV1, @@ -75,6 +76,56 @@ const FULLY_WITHHELD = + `<${COMMITMENT}> "034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c"^^ .\n`; describe('RFC-64 canonical cg-shared-v1 projection verification', () => { + it('decodes exact canonical bytes into graphless triples', () => { + expect(decodeCanonicalGraphlessProjectionV1(UTF8.encode(PUBLIC))).toEqual([ + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/age', + object: '"42"^^', + }, + { + subject: 'https://example.org/alice', + predicate: 'https://schema.org/name', + object: '"Alice"', + }, + ]); + expect(decodeCanonicalGraphlessProjectionV1(UTF8.encode( + ' .\n', + ))).toEqual([{ + subject: 'https://example.org/s', + predicate: 'https://example.org/p', + object: 'https://example.org/o', + }]); + }); + + it.each([ + { + name: 'graph term', + bytes: UTF8.encode(' .\n'), + code: 'projection-iri', + }, + { + name: 'raw byte disorder', + bytes: UTF8.encode(PUBLIC.split('\n').filter(Boolean).reverse().join('\n') + '\n'), + code: 'projection-order', + }, + { + name: 'invalid UTF-8', + bytes: new Uint8Array([ + ...UTF8.encode(' "'), + 0xc3, + 0x28, + ...UTF8.encode('" .\n'), + ]), + code: 'projection-utf8', + }, + ])('rejects $name before returning triples', ({ bytes, code }) => { + expectFailure( + () => decodeCanonicalGraphlessProjectionV1(bytes), + code as CgSharedProjectionErrorCode, + ); + }); + it.each([ { name: 'public', From e12e0ac11f29f4fca5d3444a9cbe479930054039 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 10 Aug 2026 00:53:44 +0200 Subject: [PATCH 16/18] fix(core): stream canonical projection verification --- packages/core/src/cg-shared-projection.ts | 116 ++++++++++-------- .../core/test/cg-shared-projection.test.ts | 9 ++ 2 files changed, 76 insertions(+), 49 deletions(-) diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index 367df4ec4f..592558e146 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -119,15 +119,20 @@ export function decodeCanonicalGraphlessProjectionV1( limits: CgSharedProjectionVerificationLimitsV1 = DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, ): readonly Readonly[] { - const triples = decodeCanonicalProjectionTriplesV1( + const triples: Readonly[] = []; + walkCanonicalProjectionLinesV1( projectionBytes, normalizeVerificationLimits(limits), + undefined, + ({ subject, predicate, object }) => { + triples.push(Object.freeze({ + subject, + predicate, + object: object.startsWith('<') ? object.slice(1, -1) : object, + })); + }, ); - return Object.freeze(triples.map(({ subject, predicate, object }) => Object.freeze({ - subject, - predicate, - object: object.startsWith('<') ? object.slice(1, -1) : object, - }))); + return Object.freeze(triples); } /** @@ -203,7 +208,6 @@ interface CanonicalProjectionTripleV1 { readonly subject: string; readonly predicate: string; readonly object: string; - readonly leaf: Uint8Array; } interface VerifiedCgSharedProjectionStateV1 { @@ -437,46 +441,52 @@ function verifyCanonicalProjectionBytes( const leaves: Uint8Array[] = []; let anchor: CanonicalProjectionTripleV1 | undefined; let privateHash: CanonicalProjectionTripleV1 | undefined; - const triples = decodeCanonicalProjectionTriplesV1(projectionBytes, limits); const signedPublicTripleCount = BigInt(seal.publicTripleCount); - if (BigInt(triples.length) !== signedPublicTripleCount) { - fail( - 'projection-public-count', - 'canonical projection line count differs from seal publicTripleCount', - ); - } const reservedCommitmentSubject = `${seal.kaUal}${CG_SHARED_PRIVATE_COMMITMENT_SUFFIX_V1}`; - for (let index = 0; index < triples.length; index += 1) { - const triple = triples[index]; - const lineNumber = index + 1; - if ( - triple.subject === reservedCommitmentSubject - && triple.predicate !== CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 - && triple.predicate !== CG_SHARED_PRIVATE_HASH_PREDICATE_V1 - ) { - fail( - 'projection-private-subject', - `projection line ${lineNumber} reuses the reserved commitment subject`, - ); - } - if (triple.predicate === CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1) { - if (anchor !== undefined) { - fail('projection-private-cardinality', 'projection contains duplicate private anchors'); + const lineCount = walkCanonicalProjectionLinesV1( + projectionBytes, + limits, + signedPublicTripleCount, + (triple, lineNumber) => { + if ( + triple.subject === reservedCommitmentSubject + && triple.predicate !== CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1 + && triple.predicate !== CG_SHARED_PRIVATE_HASH_PREDICATE_V1 + ) { + fail( + 'projection-private-subject', + `projection line ${lineNumber} reuses the reserved commitment subject`, + ); } - anchor = triple; - } else if (triple.predicate === CG_SHARED_PRIVATE_HASH_PREDICATE_V1) { - if (privateHash !== undefined) { - fail('projection-private-cardinality', 'projection contains duplicate private hashes'); + if (triple.predicate === CG_SHARED_PRIVATE_ANCHOR_PREDICATE_V1) { + if (anchor !== undefined) { + fail('projection-private-cardinality', 'projection contains duplicate private anchors'); + } + anchor = triple; + } else if (triple.predicate === CG_SHARED_PRIVATE_HASH_PREDICATE_V1) { + if (privateHash !== undefined) { + fail('projection-private-cardinality', 'projection contains duplicate private hashes'); + } + privateHash = triple; + } else if (triple.predicate.startsWith(PRIVATE_COMMITMENT_PREDICATE_PREFIX)) { + fail( + 'projection-private-predicate', + `projection line ${lineNumber} uses an unknown reserved private-data predicate`, + ); } - privateHash = triple; - } else if (triple.predicate.startsWith(PRIVATE_COMMITMENT_PREDICATE_PREFIX)) { - fail( - 'projection-private-predicate', - `projection line ${lineNumber} uses an unknown reserved private-data predicate`, - ); - } - leaves.push(triple.leaf); + leaves.push(hashTripleV10( + triple.subject, + triple.predicate, + triple.object, + )); + }, + ); + if (BigInt(lineCount) !== signedPublicTripleCount) { + fail( + 'projection-public-count', + 'canonical projection line count differs from seal publicTripleCount', + ); } assertPrivateCommitment(anchor, privateHash, seal); @@ -496,10 +506,12 @@ function verifyCanonicalProjectionBytes( return Object.freeze({ publicRoot, privateDataHash, assertionMerkleRoot }); } -function decodeCanonicalProjectionTriplesV1( +function walkCanonicalProjectionLinesV1( projectionBytes: Uint8Array, limits: Readonly, -): readonly CanonicalProjectionTripleV1[] { + expectedTripleCount: bigint | undefined, + visit: (triple: CanonicalProjectionTripleV1, lineNumber: number) => void, +): number { if (!(projectionBytes instanceof Uint8Array)) { fail('projection-input', 'canonical projection bytes must be a Uint8Array'); } @@ -521,7 +533,7 @@ function decodeCanonicalProjectionTriplesV1( fail('projection-line-ending', 'canonical projection must end with one LF'); } - const triples: CanonicalProjectionTripleV1[] = []; + let lineCount = 0; let previousLine: Uint8Array | undefined; let lineStart = 0; for (let cursor = 0; cursor < projectionBytes.byteLength; cursor += 1) { @@ -531,10 +543,16 @@ function decodeCanonicalProjectionTriplesV1( } if (byte !== 0x0a) continue; const line = projectionBytes.subarray(lineStart, cursor); - const lineNumber = triples.length + 1; + const lineNumber = lineCount + 1; if (lineNumber > limits.maxPublicTriples) { fail('projection-resource-refused', 'projection exceeds the local in-memory triple limit'); } + if (expectedTripleCount !== undefined && BigInt(lineNumber) > expectedTripleCount) { + fail( + 'projection-public-count', + 'projection contains more lines than the signed publicTripleCount', + ); + } if (line.byteLength === 0) { fail('projection-line', `projection line ${lineNumber} is empty`); } @@ -553,14 +571,15 @@ function decodeCanonicalProjectionTriplesV1( fail('projection-order', `projection line ${lineNumber} is not in raw UTF-8 order`); } } - triples.push(parseCanonicalProjectionLine(line, lineNumber)); + visit(parseCanonicalProjectionLine(line, lineNumber), lineNumber); + lineCount = lineNumber; previousLine = line; lineStart = cursor + 1; } if (lineStart !== projectionBytes.byteLength) { fail('projection-line-ending', 'projection contains bytes after its final complete line'); } - return Object.freeze(triples); + return lineCount; } function assertPrivateCommitment( @@ -661,7 +680,6 @@ function parseCanonicalProjectionLine( subject: subject.value, predicate: predicate.value, object, - leaf: hashTripleV10(subject.value, predicate.value, object), }); } diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts index 61ea4a0be8..4440beea29 100644 --- a/packages/core/test/cg-shared-projection.test.ts +++ b/packages/core/test/cg-shared-projection.test.ts @@ -126,6 +126,15 @@ describe('RFC-64 canonical cg-shared-v1 projection verification', () => { ); }); + it('rejects the first line beyond the signed count before parsing it', () => { + const firstLine = PUBLIC.split('\n')[0] + '\n'; + const fixture = makeFixtureBytes( + new Uint8Array([...UTF8.encode(firstLine), 0xff, 0x0a]), + sealForProjection(firstLine, '0', null), + ); + expectFailure(() => verifyProjection(fixture), 'projection-public-count'); + }); + it.each([ { name: 'public', From e0291e8dd8452c716314d635185a5fb256ea710a Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 10 Aug 2026 01:18:19 +0200 Subject: [PATCH 17/18] fix(agent): validate received profile projection --- .../agent/src/system-records/receiver-v1.ts | 18 +- .../agent-profile-receiver-v1-fixture.ts | 134 ++++++++++++++ .../test/system-record-receiver-v1.test.ts | 164 +++++++++++++----- packages/core/src/cg-shared-projection.ts | 8 +- .../core/test/cg-shared-projection.test.ts | 10 +- 5 files changed, 277 insertions(+), 57 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 5b0101ba95..2dcad3cdd0 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -1,10 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 import { - decodeCanonicalGraphlessProjectionV1, decodeOpaqueKaBundleV1, + parseCanonicalGraphlessProjectionLinesV1, } from '@origintrail-official/dkg-core'; import { + assertAgentProfileProjectionIdentityV1, + assertAgentProfileProjectionSchemaV1, buildAgentProfileVerificationClosureV1, copyBoundedSystemRecordBytesV1, computeOwnedSubjectTableDigestV1, @@ -262,7 +264,7 @@ async function buildVerifiedActiveCandidateFactsV1( ); const decodedBundle = decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes); const canonicalProjectionBytes = Uint8Array.from(decodedBundle.projectionBytes); - const projectionQuads = Object.freeze(decodeCanonicalGraphlessProjectionV1( + const projectionQuads = Object.freeze(parseCanonicalGraphlessProjectionLinesV1( canonicalProjectionBytes, ).map(({ subject, predicate, object }) => Object.freeze({ subject, @@ -270,6 +272,12 @@ async function buildVerifiedActiveCandidateFactsV1( object, graph: '', }))); + if (BigInt(canonicalProjectionBytes.byteLength) !== BigInt(head.projectionBytes)) { + throw new Error('profile bundle projection byte count does not bind the verified head'); + } + if (BigInt(projectionQuads.length) !== BigInt(head.projectionQuads)) { + throw new Error('profile bundle projection quad count does not bind the verified head'); + } const resolvedSubjectTableArtifact = await resolveArtifact({ type: 'object', objectKind: 'owned-subject-table', @@ -294,6 +302,12 @@ async function buildVerifiedActiveCandidateFactsV1( || BigInt(ownedSubjectTable.length) !== BigInt(head.ownedSubjectCount)) { throw new Error('active profile owned-subject table does not bind the verified head'); } + assertAgentProfileProjectionSchemaV1( + head.rootSubject, + ownedSubjectTable, + projectionQuads, + ); + assertAgentProfileProjectionIdentityV1(head, projectionQuads); return Object.freeze({ head, diff --git a/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts b/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts index 25a6ee01e1..a31571f08a 100644 --- a/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts +++ b/packages/agent/test/support/agent-profile-receiver-v1-fixture.ts @@ -8,6 +8,7 @@ import { computeSystemRecordStableKeyHashV1, digestSystemRecordBytesV1, SYSTEM_RECORD_DIGEST_DOMAINS_V1, + type AgentProfileActiveHeadObjectV1, type AgentProfileAuthorityTransitionV1, type SystemRecordInventoryRowV1, } from '@origintrail-official/dkg-core/system-record-v1'; @@ -200,10 +201,19 @@ export async function publishedReceiverFixtureWithProjectionBytes( SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, encoded.bundleBytes, ); + const projectionQuadCount = String(projectionBytes.reduce( + (count, byte) => count + (byte === 0x0a ? 1 : 0), + 0, + )); const envelope = await signHeadEnvelope(Object.freeze({ ...fixture.envelope.object, bundleDigest, projectionBytes: String(projectionBytes.byteLength), + projectionQuads: projectionQuadCount, + graphScopedAuthorSeal: Object.freeze({ + ...fixture.envelope.object.graphScopedAuthorSeal, + publicTripleCount: projectionQuadCount, + }), }), fixture.peerSigner, fixture.evmSigner); const headArtifact = envelopeArtifact('agent-profile-head', envelope); const bundleArtifact = Object.freeze({ @@ -229,6 +239,130 @@ export async function publishedReceiverFixtureWithProjectionBytes( }); } +export async function publishedReceiverFixtureWithHeadPatch( + patch: Partial + | ((head: AgentProfileActiveHeadObjectV1) => Partial), +) { + const fixture = await publishedReceiverFixture(); + const resolvedPatch = typeof patch === 'function' ? patch(fixture.envelope.object) : patch; + const envelope = await signHeadEnvelope(Object.freeze({ + ...fixture.envelope.object, + ...resolvedPatch, + }), fixture.peerSigner, fixture.evmSigner); + const headArtifact = envelopeArtifact('agent-profile-head', envelope); + const resolve = vi.fn(async (lookup: SystemRecordArtifactLookupV1, signal: AbortSignal) => { + if (lookup.type === 'object' + && lookup.objectKind === headArtifact.objectKind + && lookup.objectDigest === headArtifact.objectDigest) return headArtifact; + return fixture.store.resolve(lookup, signal); + }); + return Object.freeze({ + ...fixture, + envelope, + artifacts: Object.freeze({ resolve }), + row: Object.freeze({ ...fixture.row, headDigest: envelope.objectDigest }), + }); +} + +export function appendCanonicalProjectionLine( + projectionBytes: Uint8Array, + line: string, +): Uint8Array { + const lines = new TextDecoder().decode(projectionBytes).split('\n').filter(Boolean); + lines.push(line); + lines.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + return new TextEncoder().encode(`${lines.join('\n')}\n`); +} + +export const RECEIVER_HEAD_COUNT_MISMATCH_CASES = Object.freeze([ + Object.freeze({ + field: 'projectionBytes', + patch: () => ({ projectionBytes: '1' as const }), + error: /byte count/, + }), + Object.freeze({ + field: 'projectionQuads', + patch: (head: AgentProfileActiveHeadObjectV1) => ({ + projectionQuads: '1' as const, + graphScopedAuthorSeal: Object.freeze({ + ...head.graphScopedAuthorSeal, + publicTripleCount: '1' as const, + }), + }), + error: /quad count/, + }), +]); + +export const RECEIVER_CANONICAL_PROJECTION_FAILURE_CASES = Object.freeze([ + Object.freeze({ + label: 'graphful projection', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + new TextDecoder().decode(projectionBytes).replace( + ' .\n', + ' .\n', + ), + ), + error: /projection-iri/, + }), + Object.freeze({ + label: 'noncanonical projection order', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + `${new TextDecoder().decode(projectionBytes) + .split('\n').filter(Boolean).reverse().join('\n')}\n`, + ), + error: /projection-order/, + }), + Object.freeze({ + label: 'invalid UTF-8 projection', + transform: (projectionBytes: Uint8Array) => { + const altered = Uint8Array.from(projectionBytes); + const literalStart = altered.indexOf(0x22); + if (literalStart < 0) throw new Error('fixture projection has no literal'); + altered[literalStart + 1] = 0xff; + return altered; + }, + error: /projection-utf8/, + }), +]); + +export const RECEIVER_PROFILE_PROJECTION_FAILURE_CASES = Object.freeze([ + Object.freeze({ + label: 'unowned projection subject', + transform: (projectionBytes: Uint8Array) => appendCanonicalProjectionLine( + projectionBytes, + ' "intruder" .', + ), + error: /unowned subject/, + }), + Object.freeze({ + label: 'disallowed profile predicate', + transform: (projectionBytes: Uint8Array) => { + const projection = new TextDecoder().decode(projectionBytes); + const rootLine = projection.split('\n').find((line) => + line.includes('')); + const rootSubject = rootLine?.slice(0, rootLine.indexOf(' ')); + if (rootSubject === undefined || !rootSubject.startsWith('<')) { + throw new Error('fixture projection has no root identity line'); + } + return appendCanonicalProjectionLine( + projectionBytes, + `${rootSubject} "x" .`, + ); + }, + error: /disallowed profile predicate/, + }), + Object.freeze({ + label: 'mismatched peer identity', + transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( + new TextDecoder().decode(projectionBytes).split('\n').map((line) => + line.includes('') + ? line.replace(/ "[^"]+" \.$/, ' "different-peer" .') + : line).join('\n'), + ), + error: /signed peerId/, + }), +]); + export function compareReceiverQuad( left: { subject: string; predicate: string; object: string; graph: string }, right: { subject: string; predicate: string; object: string; graph: string }, diff --git a/packages/agent/test/system-record-receiver-v1.test.ts b/packages/agent/test/system-record-receiver-v1.test.ts index 748bac8e14..93588c1cd9 100644 --- a/packages/agent/test/system-record-receiver-v1.test.ts +++ b/packages/agent/test/system-record-receiver-v1.test.ts @@ -28,7 +28,11 @@ import { DEFAULT_MONOTONIC_APPLY_TIMING, preparedFixtureApply, publishedReceiverFixture as publishedFixture, + publishedReceiverFixtureWithHeadPatch, publishedReceiverFixtureWithProjectionBytes, + RECEIVER_CANONICAL_PROJECTION_FAILURE_CASES, + RECEIVER_HEAD_COUNT_MISMATCH_CASES, + RECEIVER_PROFILE_PROJECTION_FAILURE_CASES, rotatedPublishedReceiverFixture as rotatedPublishedFixture, } from './support/agent-profile-receiver-v1-fixture.js'; @@ -232,6 +236,56 @@ describe('agent-profile system-record active receiver', () => { expect(admittedDeadlineMs).not.toBe(validUntilUnixMs); }); + it('preserves an authenticated existing deadline when it is tighter', async () => { + const fixture = await publishedFixture(); + const validUntilUnixMs = Date.parse(fixture.envelope.object.validUntil); + const nowMs = vi.fn() + .mockReturnValueOnce(validUntilUnixMs - 100) + .mockReturnValueOnce(validUntilUnixMs - 80) + .mockReturnValue(validUntilUnixMs - 60); + const apply = vi.fn(() => ({ + outcome: 'applied' as const, + stateRevision: '6', + appliedStateDigest: `0x${'8'.repeat(64)}`, + })); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs, + verifyCurrentBundle: () => true, + prepareCandidateApply: () => Object.freeze({ + existingMonotonicDeadlineMs: 5_025, + monotonicNowMs: 5_000, + apply, + }), + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .resolves.toMatchObject({ outcome: 'applied' }); + expect(apply).toHaveBeenCalledOnce(); + expect(apply).toHaveBeenCalledWith(5_025); + }); + + it('rejects an already-expired authenticated monotonic deadline', async () => { + const fixture = await publishedFixture(); + const apply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.store, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle: () => true, + prepareCandidateApply: () => Object.freeze({ + existingMonotonicDeadlineMs: 5_000, + monotonicNowMs: 5_000, + apply, + }), + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(/monotonic apply admission is expired/); + expect(apply).not.toHaveBeenCalled(); + }); + it('invokes the prepared lifecycle apply entry exactly once', async () => { const fixture = await publishedFixture(); const apply = vi.fn(() => ({ @@ -476,56 +530,72 @@ describe('agent-profile system-record active receiver', () => { expect(prepareCandidateApply).not.toHaveBeenCalled(); }); - it.each([ - { - label: 'graphful projection', - transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( - new TextDecoder().decode(projectionBytes).replace( - ' .\n', - ' .\n', - ), - ), - error: /projection-iri/, - }, - { - label: 'noncanonical projection order', - transform: (projectionBytes: Uint8Array) => new TextEncoder().encode( - `${new TextDecoder().decode(projectionBytes) - .split('\n').filter(Boolean).reverse().join('\n')}\n`, - ), - error: /projection-order/, + it.each(RECEIVER_HEAD_COUNT_MISMATCH_CASES)( + 'rejects when signed $field does not match the retained projection', + async ({ patch, error }) => { + const fixture = await publishedReceiverFixtureWithHeadPatch(patch); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledOnce(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }, - { - label: 'invalid UTF-8 projection', - transform: (projectionBytes: Uint8Array) => { - const altered = Uint8Array.from(projectionBytes); - const literalStart = altered.indexOf(0x22); - if (literalStart < 0) throw new Error('fixture projection has no literal'); - altered[literalStart + 1] = 0xff; - return altered; - }, - error: /projection-utf8/, + ); + + it.each(RECEIVER_CANONICAL_PROJECTION_FAILURE_CASES)( + 'rejects a signed $label after boolean bundle verification', async ({ + transform, + error, + }) => { + const fixture = await publishedReceiverFixtureWithProjectionBytes(transform); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); + expect(prepareCandidateApply).not.toHaveBeenCalled(); }, - ])('rejects a signed $label after boolean bundle verification', async ({ - transform, - error, - }) => { - const fixture = await publishedReceiverFixtureWithProjectionBytes(transform); - const verifyCurrentBundle = vi.fn(() => true); - const prepareCandidateApply = vi.fn(); - const receiver = createAgentProfileReceiverV1({ - networkId: NETWORK, - artifacts: fixture.artifacts, - nowMs: () => PRODUCER_FIXTURE_NOW_MS, - verifyCurrentBundle, - prepareCandidateApply, - }); + ); - await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) - .rejects.toThrow(error); - expect(verifyCurrentBundle).toHaveBeenCalledTimes(1); - expect(prepareCandidateApply).not.toHaveBeenCalled(); - }); + it.each(RECEIVER_PROFILE_PROJECTION_FAILURE_CASES)( + 'rejects a signed $label before lifecycle preparation', async ({ + transform, + error, + }) => { + const fixture = await publishedReceiverFixtureWithProjectionBytes(transform); + const verifyCurrentBundle = vi.fn(() => true); + const prepareCandidateApply = vi.fn(); + const receiver = createAgentProfileReceiverV1({ + networkId: NETWORK, + artifacts: fixture.artifacts, + nowMs: () => PRODUCER_FIXTURE_NOW_MS, + verifyCurrentBundle, + prepareCandidateApply, + }); + + await expect(receiver.receiveActive(fixture.row, new AbortController().signal)) + .rejects.toThrow(error); + expect(verifyCurrentBundle).toHaveBeenCalledOnce(); + expect(prepareCandidateApply).not.toHaveBeenCalled(); + }, + ); it('isolates signed bundle bytes from mutations by the injected verifier', async () => { const fixture = await publishedFixture(); diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index 592558e146..595bb6b349 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -111,10 +111,12 @@ export function encodeCanonicalCgSharedPublicRootProjectionV1( } /** - * Decode exact canonical V10 projection bytes under the same bounded rules as - * cg-shared-v1 verification. The wire representation has no graph component. + * Parse exact canonical V10 projection lines under the same bounded syntactic + * rules as cg-shared-v1 verification. The wire representation has no graph + * component. This provides no authority, digest, signed-count, seal, or + * projection-schema proof; callers must establish those bindings separately. */ -export function decodeCanonicalGraphlessProjectionV1( +export function parseCanonicalGraphlessProjectionLinesV1( projectionBytes: Uint8Array, limits: CgSharedProjectionVerificationLimitsV1 = DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts index 4440beea29..a7128530f6 100644 --- a/packages/core/test/cg-shared-projection.test.ts +++ b/packages/core/test/cg-shared-projection.test.ts @@ -17,7 +17,7 @@ import { CgSharedProjectionError, assertVerifiedCgSharedProjectionForTransferV1, assertVerifiedCgSharedProjectionV1, - decodeCanonicalGraphlessProjectionV1, + parseCanonicalGraphlessProjectionLinesV1, readVerifiedCgSharedProjectionBytesV1, readVerifiedCgSharedProjectionMetadataV1, readVerifiedCgSharedProjectionV1, @@ -76,8 +76,8 @@ const FULLY_WITHHELD = + `<${COMMITMENT}> "034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c"^^ .\n`; describe('RFC-64 canonical cg-shared-v1 projection verification', () => { - it('decodes exact canonical bytes into graphless triples', () => { - expect(decodeCanonicalGraphlessProjectionV1(UTF8.encode(PUBLIC))).toEqual([ + it('parses exact canonical lines into graphless triples', () => { + expect(parseCanonicalGraphlessProjectionLinesV1(UTF8.encode(PUBLIC))).toEqual([ { subject: 'https://example.org/alice', predicate: 'https://schema.org/age', @@ -89,7 +89,7 @@ describe('RFC-64 canonical cg-shared-v1 projection verification', () => { object: '"Alice"', }, ]); - expect(decodeCanonicalGraphlessProjectionV1(UTF8.encode( + expect(parseCanonicalGraphlessProjectionLinesV1(UTF8.encode( ' .\n', ))).toEqual([{ subject: 'https://example.org/s', @@ -121,7 +121,7 @@ describe('RFC-64 canonical cg-shared-v1 projection verification', () => { }, ])('rejects $name before returning triples', ({ bytes, code }) => { expectFailure( - () => decodeCanonicalGraphlessProjectionV1(bytes), + () => parseCanonicalGraphlessProjectionLinesV1(bytes), code as CgSharedProjectionErrorCode, ); }); From 7a2bce998b2bcfe0902774b8e221bb478a9aa51e Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Mon, 10 Aug 2026 01:28:44 +0200 Subject: [PATCH 18/18] refactor(core): expose projection storage quads --- .../agent/src/system-records/receiver-v1.ts | 11 +++----- packages/core/src/cg-shared-projection.ts | 27 +++++++++++-------- .../core/test/cg-shared-projection.test.ts | 15 ++++++----- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/packages/agent/src/system-records/receiver-v1.ts b/packages/agent/src/system-records/receiver-v1.ts index 2dcad3cdd0..a1758c2a3b 100644 --- a/packages/agent/src/system-records/receiver-v1.ts +++ b/packages/agent/src/system-records/receiver-v1.ts @@ -2,7 +2,7 @@ import { decodeOpaqueKaBundleV1, - parseCanonicalGraphlessProjectionLinesV1, + parseCanonicalGraphlessProjectionStorageQuadsV1, } from '@origintrail-official/dkg-core'; import { assertAgentProfileProjectionIdentityV1, @@ -264,14 +264,9 @@ async function buildVerifiedActiveCandidateFactsV1( ); const decodedBundle = decodeOpaqueKaBundleV1(bundleArtifact.canonicalBytes); const canonicalProjectionBytes = Uint8Array.from(decodedBundle.projectionBytes); - const projectionQuads = Object.freeze(parseCanonicalGraphlessProjectionLinesV1( + const projectionQuads = parseCanonicalGraphlessProjectionStorageQuadsV1( canonicalProjectionBytes, - ).map(({ subject, predicate, object }) => Object.freeze({ - subject, - predicate, - object, - graph: '', - }))); + ); if (BigInt(canonicalProjectionBytes.byteLength) !== BigInt(head.projectionBytes)) { throw new Error('profile bundle projection byte count does not bind the verified head'); } diff --git a/packages/core/src/cg-shared-projection.ts b/packages/core/src/cg-shared-projection.ts index 595bb6b349..99bc1f4e65 100644 --- a/packages/core/src/cg-shared-projection.ts +++ b/packages/core/src/cg-shared-projection.ts @@ -77,11 +77,13 @@ export interface CgSharedPublicRootProjectionTripleV1 { readonly object: string; } -/** A graphless triple decoded from exact canonical V10 projection bytes. */ -export interface CanonicalGraphlessProjectionTripleV1 { +/** A storage-ready graphless quad parsed from canonical V10 projection bytes. */ +export interface CanonicalGraphlessProjectionStorageQuadV1 { readonly subject: string; readonly predicate: string; + /** IRI value without angle brackets, or the exact canonical literal term. */ readonly object: string; + readonly graph: ''; } /** @@ -111,30 +113,33 @@ export function encodeCanonicalCgSharedPublicRootProjectionV1( } /** - * Parse exact canonical V10 projection lines under the same bounded syntactic - * rules as cg-shared-v1 verification. The wire representation has no graph - * component. This provides no authority, digest, signed-count, seal, or - * projection-schema proof; callers must establish those bindings separately. + * Parse exact canonical V10 projection lines into storage-ready graphless + * quads under the same bounded syntactic rules as cg-shared-v1 verification. + * Subject, predicate, and IRI object terms are returned without angle brackets; + * literal objects remain exact canonical lexical terms. This provides no + * authority, digest, signed-count, seal, or projection-schema proof; callers + * must establish those bindings separately. */ -export function parseCanonicalGraphlessProjectionLinesV1( +export function parseCanonicalGraphlessProjectionStorageQuadsV1( projectionBytes: Uint8Array, limits: CgSharedProjectionVerificationLimitsV1 = DEFAULT_CG_SHARED_PROJECTION_VERIFICATION_LIMITS_V1, -): readonly Readonly[] { - const triples: Readonly[] = []; +): readonly Readonly[] { + const quads: Readonly[] = []; walkCanonicalProjectionLinesV1( projectionBytes, normalizeVerificationLimits(limits), undefined, ({ subject, predicate, object }) => { - triples.push(Object.freeze({ + quads.push(Object.freeze({ subject, predicate, object: object.startsWith('<') ? object.slice(1, -1) : object, + graph: '', })); }, ); - return Object.freeze(triples); + return Object.freeze(quads); } /** diff --git a/packages/core/test/cg-shared-projection.test.ts b/packages/core/test/cg-shared-projection.test.ts index a7128530f6..33330a75f8 100644 --- a/packages/core/test/cg-shared-projection.test.ts +++ b/packages/core/test/cg-shared-projection.test.ts @@ -17,7 +17,7 @@ import { CgSharedProjectionError, assertVerifiedCgSharedProjectionForTransferV1, assertVerifiedCgSharedProjectionV1, - parseCanonicalGraphlessProjectionLinesV1, + parseCanonicalGraphlessProjectionStorageQuadsV1, readVerifiedCgSharedProjectionBytesV1, readVerifiedCgSharedProjectionMetadataV1, readVerifiedCgSharedProjectionV1, @@ -76,25 +76,28 @@ const FULLY_WITHHELD = + `<${COMMITMENT}> "034349e1ac2b108ba81720c55dff02bcae22762921f5c8354db83e687015872c"^^ .\n`; describe('RFC-64 canonical cg-shared-v1 projection verification', () => { - it('parses exact canonical lines into graphless triples', () => { - expect(parseCanonicalGraphlessProjectionLinesV1(UTF8.encode(PUBLIC))).toEqual([ + it('parses exact canonical lines into storage-ready graphless quads', () => { + expect(parseCanonicalGraphlessProjectionStorageQuadsV1(UTF8.encode(PUBLIC))).toEqual([ { subject: 'https://example.org/alice', predicate: 'https://schema.org/age', object: '"42"^^', + graph: '', }, { subject: 'https://example.org/alice', predicate: 'https://schema.org/name', object: '"Alice"', + graph: '', }, ]); - expect(parseCanonicalGraphlessProjectionLinesV1(UTF8.encode( + expect(parseCanonicalGraphlessProjectionStorageQuadsV1(UTF8.encode( ' .\n', ))).toEqual([{ subject: 'https://example.org/s', predicate: 'https://example.org/p', object: 'https://example.org/o', + graph: '', }]); }); @@ -119,9 +122,9 @@ describe('RFC-64 canonical cg-shared-v1 projection verification', () => { ]), code: 'projection-utf8', }, - ])('rejects $name before returning triples', ({ bytes, code }) => { + ])('rejects $name before returning storage quads', ({ bytes, code }) => { expectFailure( - () => parseCanonicalGraphlessProjectionLinesV1(bytes), + () => parseCanonicalGraphlessProjectionStorageQuadsV1(bytes), code as CgSharedProjectionErrorCode, ); });