diff --git a/devnet/rfc64-cp2-private-swm-vm-recovery/README.md b/devnet/rfc64-cp2-private-swm-vm-recovery/README.md index f8fff4e36f..eda3b4bc80 100644 --- a/devnet/rfc64-cp2-private-swm-vm-recovery/README.md +++ b/devnet/rfc64-cp2-private-swm-vm-recovery/README.md @@ -2,9 +2,19 @@ This canary starts two real `DKGAgent` processes. Both nodes accept one registered private Context Graph policy and its exact roster. The author -publishes 32 signed catalog assets. The cold receiver must recover exactly -32/32 SWM assets and materialize exactly 32/32 VM assets from the finalized -chain ordinal set. +publishes 32 signed catalog assets. The cold receiver must authenticate and +activate exactly 32/32 SWM catalog payloads, materialize exactly 32/32 VM +assets from the finalized chain ordinal set, and then retire exactly 32/32 +duplicate SWM twins. The durable synchronization evidence proves the catalog +activation. For every KA, the receiver also exports a production-owned +lifecycle receipt bound to the exact catalog head, inventory digest, VM graph, +and VM post-read digest. The canary requires the receiver-owned committed-head +token, which exists only after the VM transaction commits and the exact durable +head and inventory survive their post-read; the receipt is emitted only after +SWM reconciliation. An early retirement therefore cannot pass merely because +VM appears later. Exact empty +SWM graph readback plus exact VM bytes and metadata independently prove the +intentional post-finalization retirement rather than data loss. The scale fixture does not build 500 cumulative exact sets. For a 500-asset run, it stages the diff --git a/devnet/rfc64-cp2-private-swm-vm-recovery/lifecycle-receipts.test.ts b/devnet/rfc64-cp2-private-swm-vm-recovery/lifecycle-receipts.test.ts new file mode 100644 index 0000000000..e47d5e24e4 --- /dev/null +++ b/devnet/rfc64-cp2-private-swm-vm-recovery/lifecycle-receipts.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { Digest32V1 } from '@origintrail-official/dkg-core'; + +import { + assertPrivateColdRetirementLifecycleV1, + computeFinalizedVmPostReadDigestV1, + computeFinalizedVmPostReadDigestFromHarnessReadbackV1, +} from './lifecycle-receipts.ts'; +import { wireSynchronizationEvidence } from + '../rfc64-gate2-multi-asset-completeness/synchronization-evidence-wire.ts'; + +const HEAD = `0x${'11'.repeat(32)}` as Digest32V1; +const INVENTORY = `0x${'22'.repeat(32)}` as Digest32V1; +const CONTEXT_GRAPH = '0x1111111111111111111111111111111111111111/private'; +const UAL = 'did:dkg:otp:20430/0x1111111111111111111111111111111111111111/1'; +const VM_GRAPH = 'urn:dkg:vm:1'; +const PROJECTION = ' "value" .\n'; +const FIXED_POST_READ_DIGEST = + '0xacc5e282bd297a4e0a9039f00cf699e500b0d0c7992b28133693cd3b1a95be00'; + +function receipt(overrides: Record = {}) { + return { + kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2', + contextGraphId: CONTEXT_GRAPH, + kaUal: UAL, + assertionVersion: '1', + vmGraphIri: VM_GRAPH, + vmPostReadDigest: FIXED_POST_READ_DIGEST, + vmMaterializationStatus: 'materialized', + swmReconciliationOutcome: 'retired', + ...overrides, + }; +} + +function synchronization( + receipts: unknown = [receipt()], + overrides: Record = {}, +) { + return { + catalogHeadDigest: HEAD, + inventoryDigest: INVENTORY, + finalizedSwmRetirementLifecycleReceipts: receipts, + ...overrides, + }; +} + +function expected() { + return { + catalogHeadDigest: HEAD, + inventoryDigest: INVENTORY, + contextGraphId: CONTEXT_GRAPH, + byUal: new Map([[UAL, { + assertionVersion: '1', + vmGraphIri: VM_GRAPH, + lineFramedProjectionNQuads: PROJECTION, + }]]), + }; +} + +describe('private cold retirement lifecycle certification', () => { + it('pins the exact domain-separated Keccak-256 digest contract', () => { + assert.equal( + computeFinalizedVmPostReadDigestV1(PROJECTION.slice(0, -1)), + FIXED_POST_READ_DIGEST, + ); + assert.equal( + computeFinalizedVmPostReadDigestFromHarnessReadbackV1(PROJECTION), + FIXED_POST_READ_DIGEST, + ); + assert.notEqual( + computeFinalizedVmPostReadDigestV1(PROJECTION), + FIXED_POST_READ_DIGEST, + ); + }); + + it('accepts the production V2 receipts from exactInventoryReadback and binds their head', () => { + const decoded = assertPrivateColdRetirementLifecycleV1(synchronization(), expected()); + assert.deepEqual(decoded.receipts, [receipt()]); + assert.equal(decoded.byUal.get(UAL)?.vmGraphIri, VM_GRAPH); + assert.equal(Object.isFrozen(decoded.receipts), true); + assert.equal(Object.isFrozen(decoded.receipts[0]), true); + }); + + it('preserves lifecycle receipts through populated and empty adapter readbacks', () => { + const populated = wireSynchronizationEvidence({ + ...synchronization(), + inventoryRowCount: 1, + activatedTripleCount: 2, + appliedHeadStatus: 'applied', + kaUal: UAL, + authorship: { + directoryPathObjectDigests: [], + directoryPathSignatureVariantDigests: [], + }, + catalogRowDigest: `0x${'33'.repeat(32)}`, + contentDigest: `0x${'44'.repeat(32)}`, + bundleDigest: `0x${'55'.repeat(32)}`, + swmGraph: 'urn:dkg:swm:1', + }); + const populatedDecoded = assertPrivateColdRetirementLifecycleV1( + populated, + expected(), + ); + assert.deepEqual(populatedDecoded.receipts, [receipt()]); + + const empty = wireSynchronizationEvidence({ + ...synchronization(), + inventoryRowCount: 0, + activatedTripleCount: 0, + appliedHeadStatus: 'applied', + }); + const emptyDecoded = assertPrivateColdRetirementLifecycleV1(empty, expected()); + assert.deepEqual(emptyDecoded.receipts, [receipt()]); + }); + + it('rejects malformed, duplicate, out-of-order, and non-root evidence', () => { + assert.throws( + () => assertPrivateColdRetirementLifecycleV1({}, expected()), + /head digest is missing/u, + ); + const secondUal = `${UAL.slice(0, -1)}2`; + const twoExpected = { + ...expected(), + byUal: new Map([ + [UAL, expected().byUal.get(UAL)!], + [secondUal, { ...expected().byUal.get(UAL)!, vmGraphIri: 'urn:dkg:vm:2' }], + ]), + }; + assert.throws( + () => assertPrivateColdRetirementLifecycleV1( + synchronization([receipt(), receipt()]), + twoExpected, + ), + /unexpected or duplicate KA UAL/u, + ); + assert.throws( + () => assertPrivateColdRetirementLifecycleV1( + synchronization([ + receipt({ kaUal: secondUal, vmGraphIri: 'urn:dkg:vm:2' }), + receipt(), + ]), + twoExpected, + ), + /canonical UAL order/u, + ); + assert.throws( + () => assertPrivateColdRetirementLifecycleV1( + synchronization([receipt({ subGraphName: 'unrelated-slice' })]), + expected(), + ), + /unexpected or missing fields/u, + ); + }); + + it('rejects every lifecycle state that cannot certify CP2 PASS', () => { + const invalid = [ + { contextGraphId: 'different-private-context' }, + { assertionVersion: '2' }, + { vmGraphIri: 'urn:dkg:vm:different' }, + { vmPostReadDigest: `0x${'66'.repeat(32)}` }, + { vmMaterializationStatus: 'existing' }, + { swmReconciliationOutcome: 'already-retired-finalized' }, + { swmReconciliationOutcome: 'vm-changed' }, + ]; + for (const mutation of invalid) { + assert.throws( + () => assertPrivateColdRetirementLifecycleV1( + synchronization([receipt(mutation)]), + expected(), + ), + ); + } + assert.throws( + () => assertPrivateColdRetirementLifecycleV1( + synchronization([receipt()], { catalogHeadDigest: `0x${'44'.repeat(32)}` }), + expected(), + ), + ); + assert.throws( + () => assertPrivateColdRetirementLifecycleV1( + synchronization([receipt()], { inventoryDigest: `0x${'55'.repeat(32)}` }), + expected(), + ), + ); + assert.throws( + () => computeFinalizedVmPostReadDigestFromHarnessReadbackV1(`${PROJECTION}\n`), + /exactly one trailing LF/u, + ); + }); +}); diff --git a/devnet/rfc64-cp2-private-swm-vm-recovery/lifecycle-receipts.ts b/devnet/rfc64-cp2-private-swm-vm-recovery/lifecycle-receipts.ts new file mode 100644 index 0000000000..c52fac581c --- /dev/null +++ b/devnet/rfc64-cp2-private-swm-vm-recovery/lifecycle-receipts.ts @@ -0,0 +1,209 @@ +import type { Digest32V1 } from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; + +const MAX_RECEIPTS = 1_024; +const POST_READ_DIGEST_DOMAIN_V1 = ethers.toUtf8Bytes( + 'OT-RFC-64:finalized-vm-post-read:v1\0', +); + +export interface PrivateColdRetirementLifecycleReceiptV2 { + readonly kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2'; + readonly contextGraphId: string; + readonly kaUal: string; + readonly assertionVersion: string; + readonly vmGraphIri: string; + readonly vmPostReadDigest: Digest32V1; + readonly vmMaterializationStatus: 'materialized'; + readonly swmReconciliationOutcome: 'retired'; +} + +export interface PrivateColdRetirementLifecycleExpectationV1 { + readonly catalogHeadDigest: Digest32V1; + readonly inventoryDigest: Digest32V1; + readonly contextGraphId: string; + readonly byUal: ReadonlyMap>; +} + +/** + * Assert the complete CP2 PASS boundary in one pass. This intentionally models + * only the cold, root-lane, materialized-and-retired state certified by this + * scenario; other valid production lifecycle states are not CP2 PASS states. + */ +export function assertPrivateColdRetirementLifecycleV1( + input: unknown, + expected: Readonly, +): Readonly<{ + readonly receipts: readonly Readonly[]; + readonly byUal: ReadonlyMap>; +}> { + const synchronization = requiredRecord(input, 'private cold synchronization evidence'); + exactDigest( + ownValue(synchronization, 'catalogHeadDigest', 'private synchronization head digest'), + expected.catalogHeadDigest, + 'private synchronization head digest', + ); + exactDigest( + ownValue(synchronization, 'inventoryDigest', 'private synchronization inventory digest'), + expected.inventoryDigest, + 'private synchronization inventory digest', + ); + const receiptsInput = ownValue( + synchronization, + 'finalizedSwmRetirementLifecycleReceipts', + 'private synchronization lifecycle receipts', + ); + if ( + !Array.isArray(receiptsInput) + || receiptsInput.length > MAX_RECEIPTS + || receiptsInput.length !== expected.byUal.size + ) { + throw new TypeError('private cold retirement lifecycle must be the exact bounded asset set'); + } + const byUal = new Map>(); + let previousUal: string | undefined; + const receipts = receiptsInput.map((value, index) => { + const label = `private cold lifecycle ${index}`; + const receipt = exactRecord(value, [ + 'kind', + 'contextGraphId', + 'kaUal', + 'assertionVersion', + 'vmGraphIri', + 'vmPostReadDigest', + 'vmMaterializationStatus', + 'swmReconciliationOutcome', + ], label); + const kaUal = requiredString(receipt.kaUal, `${label} KA UAL`); + const expectation = expected.byUal.get(kaUal); + if (expectation === undefined || byUal.has(kaUal)) { + throw new Error(`${label} has an unexpected or duplicate KA UAL ${kaUal}`); + } + if (previousUal !== undefined && previousUal.localeCompare(kaUal) >= 0) { + throw new Error(`${label} is out of canonical UAL order at ${kaUal}`); + } + previousUal = kaUal; + const decoded = Object.freeze({ + kind: exactString( + receipt.kind, + 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2', + `${label} kind`, + ), + contextGraphId: exactString( + receipt.contextGraphId, + expected.contextGraphId, + `${label} context graph`, + ), + kaUal, + assertionVersion: exactString( + receipt.assertionVersion, + expectation.assertionVersion, + `${label} assertion version`, + ), + vmGraphIri: exactString( + receipt.vmGraphIri, + expectation.vmGraphIri, + `${label} VM graph`, + ), + vmPostReadDigest: exactDigest( + receipt.vmPostReadDigest, + computeFinalizedVmPostReadDigestFromHarnessReadbackV1( + expectation.lineFramedProjectionNQuads, + ), + `${label} VM post-read digest`, + ), + vmMaterializationStatus: exactString( + receipt.vmMaterializationStatus, + 'materialized', + `${label} VM materialization status`, + ), + swmReconciliationOutcome: exactString( + receipt.swmReconciliationOutcome, + 'retired', + `${label} SWM reconciliation outcome`, + ), + }) satisfies PrivateColdRetirementLifecycleReceiptV2; + byUal.set(kaUal, decoded); + return decoded; + }); + return Object.freeze({ receipts: Object.freeze(receipts), byUal }); +} + +function requiredRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +} + +function ownValue(record: Record, key: string, label: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError(`${label} is missing`); + } + return descriptor.value; +} + +/** Independently derive the production v1 post-read digest from canonical N-Quads. */ +export function computeFinalizedVmPostReadDigestV1( + canonicalProjectionNQuads: string, +): Digest32V1 { + return ethers.keccak256(ethers.concat([ + POST_READ_DIGEST_DOMAIN_V1, + ethers.toUtf8Bytes(canonicalProjectionNQuads), + ])).toLowerCase() as Digest32V1; +} + +export function computeFinalizedVmPostReadDigestFromHarnessReadbackV1( + lineFramedProjectionNQuads: string, +): Digest32V1 { + if ( + !lineFramedProjectionNQuads.endsWith('\n') + || lineFramedProjectionNQuads.endsWith('\n\n') + || lineFramedProjectionNQuads.includes('\r') + ) { + throw new TypeError('private VM harness readback must have exactly one trailing LF'); + } + return computeFinalizedVmPostReadDigestV1(lineFramedProjectionNQuads.slice(0, -1)); +} + +function exactRecord( + value: unknown, + keys: readonly string[], + label: string, +): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const result = value as Record; + const actualKeys = Object.keys(result).sort(); + const expectedKeys = [...keys].sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { + throw new TypeError(`${label} has unexpected or missing fields`); + } + return result; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 16_384) { + throw new TypeError(`${label} is missing`); + } + return value; +} + +function exactDigest(value: unknown, expected: Digest32V1, label: string): Digest32V1 { + if (value !== expected) throw new TypeError(`${label} is invalid`); + return expected; +} + +function exactString( + value: unknown, + expected: T, + label: string, +): T { + if (value !== expected) throw new TypeError(`${label} is invalid`); + return expected; +} diff --git a/devnet/rfc64-cp2-private-swm-vm-recovery/package.json b/devnet/rfc64-cp2-private-swm-vm-recovery/package.json index 37f5e6a2bc..0b55a07edc 100644 --- a/devnet/rfc64-cp2-private-swm-vm-recovery/package.json +++ b/devnet/rfc64-cp2-private-swm-vm-recovery/package.json @@ -9,7 +9,7 @@ "ethers": "6.16.0" }, "scripts": { - "test": "node --import tsx --test batch-plan.test.ts", + "test": "node --import tsx --test batch-plan.test.ts lifecycle-receipts.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json", "live": "node --import tsx launch-live.ts" } diff --git a/devnet/rfc64-cp2-private-swm-vm-recovery/run.ts b/devnet/rfc64-cp2-private-swm-vm-recovery/run.ts index f25e188174..321d7ad06d 100644 --- a/devnet/rfc64-cp2-private-swm-vm-recovery/run.ts +++ b/devnet/rfc64-cp2-private-swm-vm-recovery/run.ts @@ -45,6 +45,9 @@ import { spawnGate2HarnessAgentV1, } from '../rfc64-gate2-multi-asset-completeness/two-agent-harness.ts'; import { planPrivateCatalogConstructionV1 } from './batch-plan.ts'; +import { + assertPrivateColdRetirementLifecycleV1, +} from './lifecycle-receipts.ts'; const REPO_ROOT = resolve(import.meta.dirname, '../..'); const ARTIFACT = process.env.DKG_RFC64_PRIVATE_CP2_ARTIFACT @@ -297,20 +300,27 @@ async function execute(): Promise { exact(synchronization.inventoryRowCount, ASSET_COUNT, 'private inventory row count'); exact(synchronization.activatedTripleCount, ASSET_COUNT * 2, 'private SWM triple count'); exact(synchronization.appliedHeadStatus, 'applied', 'private applied head status'); - - let swmRecovered = 0; - let vmRecovered = 0; + const lifecycleExpectationsByUal = new Map>(); const recoveredChainOrdinals = new Set(); for (const [index, value] of rows.entries()) { const row = record(value, `private row ${index}`); + exact(row.activatedTripleCount, 2, `private SWM ${index} activated triple count`); const semantic = output(await receiver.request( 'semanticGraphReadback', `private-semantic-${index}`, 'operation-completed', { swmGraph: requiredString(row.swmGraph, `private row ${index} graph`) }, ), `private semantic ${index}`); - exact(semantic.projectionNQuads, PROJECTION_NQUADS, `private SWM ${index}`); - swmRecovered += 1; + // The catalog activation evidence above proves the exact SWM payload was + // authenticated and materialized. Because this same asset is finalized + // on chain, the receiver then deliberately retires the duplicate SWM + // twin after committing the exact VM graph. + exact(semantic.activatedQuadCount, 0, `private SWM ${index} retired triple count`); + exact(semantic.projectionNQuads, '\n', `private SWM ${index} retired projection`); const kaId = requiredString(row.kaId, `private row ${index} KA ID`); const kaNumber = BigInt(kaId) & ((1n << 96n) - 1n); @@ -337,6 +347,14 @@ async function execute(): Promise { ), `private VM ${index}`); exact(vm.tripleCount, 2, `private VM ${index} triple count`); exact(vm.projectionNQuads, PROJECTION_NQUADS, `private VM ${index} projection`); + lifecycleExpectationsByUal.set(ual, Object.freeze({ + assertionVersion: '1', + vmGraphIri: vmGraph, + lineFramedProjectionNQuads: requiredString( + vm.projectionNQuads, + `private VM ${index} projection`, + ), + })); const metadata = array(vm.metadataBindings, `private VM ${index} metadata`) .map((item, metadataIndex) => record(item, `private VM ${index} metadata ${metadataIndex}`)); metadataObject(metadata, 'status', '"confirmed"'); @@ -348,13 +366,24 @@ async function execute(): Promise { // The mock finalized snapshot places every KA at block 123, transaction index 0. // The KA number above, not materializedVersion, proves the exact chain ordinal set. metadataObject(metadata, 'materializedVersion', '"123:0"'); - vmRecovered += 1; } exactJson( [...recoveredChainOrdinals].sort((left, right) => left - right), Array.from({ length: ASSET_COUNT }, (_, ordinal) => ordinal), 'private recovered finalized chain ordinal set', ); + const decodedLifecycle = assertPrivateColdRetirementLifecycleV1( + synchronization, + { + catalogHeadDigest: headDigest as Digest32V1, + inventoryDigest: requiredString( + synchronization.inventoryDigest, + 'private synchronization inventory digest', + ) as Digest32V1, + contextGraphId: CONTEXT_GRAPH_ID, + byUal: lifecycleExpectationsByUal, + }, + ); const [authorStopped, receiverStopped] = await Promise.all([ author.stop('private-author-stop'), @@ -382,8 +411,16 @@ async function execute(): Promise { finalInventoryRowCount: ASSET_COUNT, }, chainExpectedAssets: ASSET_COUNT, - swm: { expected: ASSET_COUNT, recovered: swmRecovered }, - vm: { expected: ASSET_COUNT, recovered: vmRecovered }, + swm: { + expectedActivated: ASSET_COUNT, + activated: rows.length, + expectedRetiredAfterVm: ASSET_COUNT, + retiredAfterVm: rows.length, + lifecycleReceipts: Object.freeze( + [...decodedLifecycle.receipts], + ), + }, + vm: { expected: ASSET_COUNT, recovered: recoveredChainOrdinals.size }, processBoundary: { authorExitCode: authorStopped.exit.code, receiverExitCode: receiverStopped.exit.code, @@ -391,7 +428,7 @@ async function execute(): Promise { policyDigest: POLICY_DIGEST, repository: { testedHeadCommit, trackedSourceClean: true }, runtimeManifestDigest: launch.manifest.manifestDigest, - schemaVersion: 'dkg-rfc64-cp2-private-swm-vm-recovery-v1', + schemaVersion: 'dkg-rfc64-cp2-private-swm-vm-recovery-v4', status: 'PASS', }); const receipt = atomicWriteExactBytes( @@ -399,8 +436,10 @@ async function execute(): Promise { new TextEncoder().encode(canonicalDocument(artifact as unknown as CanonicalValue)), ); process.stdout.write( - `[rfc64-private-cp2] PASS swm=${swmRecovered}/${ASSET_COUNT} ` - + `vm=${vmRecovered}/${ASSET_COUNT} artifact=${ARTIFACT} sha256=${receipt.sha256}\n`, + `[rfc64-private-cp2] PASS swm-activated=${rows.length}/${ASSET_COUNT} ` + + `swm-retired=${rows.length}/${ASSET_COUNT} ` + + `vm=${recoveredChainOrdinals.size}/${ASSET_COUNT} ` + + `artifact=${ARTIFACT} sha256=${receipt.sha256}\n`, ); operationFailed = false; } catch (error) { diff --git a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts index cb360074d3..02f6a2163a 100644 --- a/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts +++ b/devnet/rfc64-gate2-multi-asset-completeness/adapter-process.ts @@ -28,7 +28,6 @@ import { assertSubGraphNameV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, computeControlSignatureVariantDigestHex, - parseDeterministicKnowledgeAssetUal, type AuthorCatalogScopeV1, type Digest32V1, type EvmAddressV1, @@ -55,6 +54,7 @@ import { import { sealGate2ExecutedRuntimeManifestV1 } from './runtime-load-hook.ts'; import { stagePrivateCatalogBulkPredecessorV1 } from '../rfc64-cp2-private-swm-vm-recovery/bulk-predecessor.ts'; +import { wireSynchronizationEvidence } from './synchronization-evidence-wire.ts'; const role = process.argv[2]; const dataDirInput = process.env.DKG_RFC64_GATE2_ADAPTER_DATA_DIR; @@ -900,118 +900,6 @@ function compareQuad(left: Quad, right: Quad): number { return leftKey.localeCompare(rightKey); } -function wireSynchronizationEvidence(output: unknown): unknown { - if (output === null) return null; - const evidence = plainRecord(output, 'exact synchronization evidence'); - if (evidence.inventoryRowCount === 0) return evidence; - const wired = evidence.inventoryRowCount === 1 - ? [wireLegacySingleRowSynchronizationEvidence(evidence)] - : plainArray(evidence.rows, 'synchronization.rows').map( - (value, index) => wireMultiRowSynchronizationEvidence(value, index), - ); - const verifiedControlObjectCount = requireUniformControlObjectCount(wired); - return Object.freeze({ - inventoryDigest: evidence.inventoryDigest, - catalogHeadDigest: evidence.catalogHeadDigest, - inventoryRowCount: evidence.inventoryRowCount, - activatedTripleCount: evidence.activatedTripleCount, - appliedHeadStatus: evidence.appliedHeadStatus, - rows: Object.freeze(wired.map((entry) => entry.row)), - verifiedControlObjectCount, - }); -} - -interface WiredSynchronizationRow { - readonly row: Readonly>; - readonly verifiedControlObjectCount: number; -} - -function wireLegacySingleRowSynchronizationEvidence( - evidence: Record, -): WiredSynchronizationRow { - const label = 'synchronization.legacySingleRow'; - const kaUal = requiredString(evidence.kaUal, `${label}.kaUal`); - return wireSynchronizationRow( - evidence, - label, - canonicalDecimalWire(packedKaIdFromUal(kaUal), `${label}.kaId`), - null, - ); -} - -function wireMultiRowSynchronizationEvidence( - value: unknown, - index: number, -): WiredSynchronizationRow { - const label = `synchronization.rows[${index}]`; - const row = plainRecord(value, label); - return wireSynchronizationRow( - row, - label, - canonicalDecimalWire(row.kaId, `${label}.kaId`), - requiredDigest(row.sealDigest, `${label}.sealDigest`), - ); -} - -function wireSynchronizationRow( - row: Record, - label: string, - kaId: string, - sealDigest: Digest32V1 | null, -): WiredSynchronizationRow { - const authorship = plainRecord(row.authorship, `${label}.authorship`); - const path = plainArray( - authorship.directoryPathObjectDigests, - `${label}.authorship.directoryPathObjectDigests`, - ); - const variants = plainArray( - authorship.directoryPathSignatureVariantDigests, - `${label}.authorship.directoryPathSignatureVariantDigests`, - ); - if (path.length !== variants.length) { - throw new Error('synchronization authorship path evidence is incomplete'); - } - return Object.freeze({ - row: Object.freeze({ - kaId, - catalogRowDigest: row.catalogRowDigest, - contentDigest: row.contentDigest, - sealDigest, - bundleDigest: row.bundleDigest, - kaUal: requiredString(row.kaUal, `${label}.kaUal`), - activatedTripleCount: row.activatedTripleCount, - swmGraph: row.swmGraph, - }), - verifiedControlObjectCount: 3 + path.length, - }); -} - -function requireUniformControlObjectCount( - rows: readonly WiredSynchronizationRow[], -): number { - const first = rows[0]; - if (first === undefined) { - throw new Error('non-empty synchronization evidence contains no exact rows'); - } - for (const row of rows.slice(1)) { - if (row.verifiedControlObjectCount !== first.verifiedControlObjectCount) { - throw new Error('synchronization rows disagree on the verified control-object closure'); - } - } - return first.verifiedControlObjectCount; -} - -function packedKaIdFromUal(kaUal: string): string { - const parsed = parseDeterministicKnowledgeAssetUal(kaUal); - return ((BigInt(parsed.agentAddress) << 96n) | BigInt(parsed.kaNumber)).toString(); -} - -function canonicalDecimalWire(value: unknown, label: string): string { - if (typeof value === 'bigint' && value >= 0n) return value.toString(); - if (typeof value === 'string' && /^(0|[1-9][0-9]*)$/u.test(value)) return value; - throw new TypeError(`${label} is not a canonical non-negative integer`); -} - function inspectGate2ProductCapabilities(currentAgent: DKGAgent): Record { const surface = currentAgent as unknown as Record; return Object.freeze({ diff --git a/devnet/rfc64-gate2-multi-asset-completeness/synchronization-evidence-wire.ts b/devnet/rfc64-gate2-multi-asset-completeness/synchronization-evidence-wire.ts new file mode 100644 index 0000000000..fd21f86cc1 --- /dev/null +++ b/devnet/rfc64-gate2-multi-asset-completeness/synchronization-evidence-wire.ts @@ -0,0 +1,163 @@ +import { + parseDeterministicKnowledgeAssetUal, + type Digest32V1, +} from '@origintrail-official/dkg-core'; + +/** + * Serialize the production exactInventoryReadback result at the adapter + * boundary. Lifecycle receipts are preserved for both populated and empty + * inventory responses. + */ +export function wireSynchronizationEvidence(output: unknown): unknown { + if (output === null) return null; + const evidence = plainRecord(output, 'exact synchronization evidence'); + const lifecycleReceipts = Object.freeze(plainArray( + evidence.finalizedSwmRetirementLifecycleReceipts ?? [], + 'synchronization.finalizedSwmRetirementLifecycleReceipts', + ).map((value, index) => plainRecord(value, `synchronization lifecycle ${index}`))); + if (evidence.inventoryRowCount === 0) { + return Object.freeze({ + ...evidence, + finalizedSwmRetirementLifecycleReceipts: lifecycleReceipts, + }); + } + const wired = evidence.inventoryRowCount === 1 + ? [wireLegacySingleRowSynchronizationEvidence(evidence)] + : plainArray(evidence.rows, 'synchronization.rows').map( + (value, index) => wireMultiRowSynchronizationEvidence(value, index), + ); + const verifiedControlObjectCount = requireUniformControlObjectCount(wired); + return Object.freeze({ + inventoryDigest: evidence.inventoryDigest, + catalogHeadDigest: evidence.catalogHeadDigest, + inventoryRowCount: evidence.inventoryRowCount, + activatedTripleCount: evidence.activatedTripleCount, + appliedHeadStatus: evidence.appliedHeadStatus, + rows: Object.freeze(wired.map((entry) => entry.row)), + verifiedControlObjectCount, + finalizedSwmRetirementLifecycleReceipts: lifecycleReceipts, + }); +} + +interface WiredSynchronizationRow { + readonly row: Readonly>; + readonly verifiedControlObjectCount: number; +} + +function wireLegacySingleRowSynchronizationEvidence( + evidence: Record, +): WiredSynchronizationRow { + const label = 'synchronization.legacySingleRow'; + const kaUal = requiredString(evidence.kaUal, `${label}.kaUal`); + return wireSynchronizationRow( + evidence, + label, + canonicalDecimalWire(packedKaIdFromUal(kaUal), `${label}.kaId`), + null, + ); +} + +function wireMultiRowSynchronizationEvidence( + value: unknown, + index: number, +): WiredSynchronizationRow { + const label = `synchronization.rows[${index}]`; + const row = plainRecord(value, label); + return wireSynchronizationRow( + row, + label, + canonicalDecimalWire(row.kaId, `${label}.kaId`), + requiredDigest(row.sealDigest, `${label}.sealDigest`), + ); +} + +function wireSynchronizationRow( + row: Record, + label: string, + kaId: string, + sealDigest: Digest32V1 | null, +): WiredSynchronizationRow { + const authorship = plainRecord(row.authorship, `${label}.authorship`); + const path = plainArray( + authorship.directoryPathObjectDigests, + `${label}.authorship.directoryPathObjectDigests`, + ); + const variants = plainArray( + authorship.directoryPathSignatureVariantDigests, + `${label}.authorship.directoryPathSignatureVariantDigests`, + ); + if (path.length !== variants.length) { + throw new Error('synchronization authorship path evidence is incomplete'); + } + return Object.freeze({ + row: Object.freeze({ + kaId, + catalogRowDigest: row.catalogRowDigest, + contentDigest: row.contentDigest, + sealDigest, + bundleDigest: row.bundleDigest, + kaUal: requiredString(row.kaUal, `${label}.kaUal`), + activatedTripleCount: row.activatedTripleCount, + swmGraph: row.swmGraph, + }), + verifiedControlObjectCount: 3 + path.length, + }); +} + +function requireUniformControlObjectCount( + rows: readonly WiredSynchronizationRow[], +): number { + const first = rows[0]; + if (first === undefined) { + throw new Error('non-empty synchronization evidence contains no exact rows'); + } + for (const row of rows.slice(1)) { + if (row.verifiedControlObjectCount !== first.verifiedControlObjectCount) { + throw new Error('synchronization rows disagree on the verified control-object closure'); + } + } + return first.verifiedControlObjectCount; +} + +function packedKaIdFromUal(kaUal: string): string { + const parsed = parseDeterministicKnowledgeAssetUal(kaUal); + return ((BigInt(parsed.agentAddress) << 96n) | BigInt(parsed.kaNumber)).toString(); +} + +function canonicalDecimalWire(value: unknown, label: string): string { + if (typeof value === 'bigint' && value >= 0n) return value.toString(); + if (typeof value === 'string' && /^(0|[1-9][0-9]*)$/u.test(value)) return value; + throw new TypeError(`${label} is not a canonical non-negative integer`); +} + +function plainRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError(`${label} must be a plain object`); + } + return value as Record; +} + +function plainArray(value: unknown, label: string): unknown[] { + if (!Array.isArray(value) || value.length > 1_024) { + throw new TypeError(`${label} must be a bounded Array`); + } + return value; +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) { + throw new TypeError(`${label} must be a bounded non-empty string`); + } + return value; +} + +function requiredDigest(value: unknown, label: string): Digest32V1 { + if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/u.test(value)) { + throw new TypeError(`${label} must be a canonical digest`); + } + return value as Digest32V1; +} diff --git a/package.json b/package.json index e710eb4422..f68afcb9db 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "test:m1:rfc64-private-swm-recovery": "node --import tsx devnet/rfc64-cp1-private-swm-recovery/launch-live.ts", "typecheck:m1:rfc64-private-swm-recovery": "tsc --project devnet/rfc64-cp1-private-swm-recovery/tsconfig.json", "test:m2:rfc64-private-swm-vm-recovery": "node --import tsx devnet/rfc64-cp2-private-swm-vm-recovery/launch-live.ts", + "test:m2:rfc64-private-swm-vm-recovery:unit": "pnpm --filter @devnet/rfc64-cp2-private-swm-vm-recovery test", "typecheck:m2:rfc64-private-swm-vm-recovery": "tsc --project devnet/rfc64-cp2-private-swm-vm-recovery/tsconfig.json", "test:m3:rfc64-private-provider-failover": "node --import tsx devnet/rfc64-cp3-private-provider-failover/launch-live.ts", "typecheck:m3:rfc64-private-provider-failover": "tsc --project devnet/rfc64-cp3-private-provider-failover/tsconfig.json", diff --git a/packages/agent/package.json b/packages/agent/package.json index 8f72542d69..2fc9f36097 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -43,13 +43,14 @@ "./dist/rfc64/catalog-access-policy-v1.js": null, "./dist/rfc64/catalog-authority-config-v1.js": null, "./dist/rfc64/catalog-rollout-authority-v1.js": null, - "./dist/rfc64/catalog-applied-head-evidence-v1.js": null, "./dist/rfc64/catalog-rollout-authority-reconciliation-v1.js": null, "./dist/rfc64/applied-catalog-authority-transition-v1.js": null, "./dist/rfc64/catalog-semantic-authority-transition-v1.js": null, "./dist/rfc64/public-catalog-native-errors-v1.js": null, "./dist/rfc64/catalog-applied-head-coordinator-v1.js": null, "./dist/rfc64/catalog-synchronization-evidence-v1.js": null, + "./dist/rfc64/finalized-private-placement-repair-store-v1.js": null, + "./dist/rfc64/finalized-swm-retirement-lifecycle-receipt-v1.js": null, "./dist/rfc64/catalog-native-scoped-read-capability-v1-internal.js": null, "./dist/rfc64/catalog-native-scoped-read-provider-v1.js": null, "./dist/rfc64/catalog-synchronization-error-v1.js": null, @@ -80,6 +81,7 @@ "./dist/rfc64/public-catalog-current-head-discovery-v1.js": null, "./dist/rfc64/public-catalog-inventory-completeness-v1.js": null, "./dist/rfc64/public-catalog-native-receiver-v1.js": null, + "./dist/rfc64/public-catalog-native-committed-head-token-v1.js": null, "./dist/rfc64/public-catalog-native-reconciler-v1.js": null, "./dist/rfc64/public-catalog-native-transport-v1.js": null, "./dist/rfc64/public-open-catalog-scope-v1.js": null, diff --git a/packages/agent/scripts/test-package-root.mjs b/packages/agent/scripts/test-package-root.mjs index 9103b44e7b..02c13dc4c0 100644 --- a/packages/agent/scripts/test-package-root.mjs +++ b/packages/agent/scripts/test-package-root.mjs @@ -203,7 +203,6 @@ const blockedRfc64Modules = [ 'catalog-access-policy-v1.js', 'catalog-authority-config-v1.js', 'catalog-rollout-authority-v1.js', - 'catalog-applied-head-evidence-v1.js', 'catalog-rollout-authority-reconciliation-v1.js', 'applied-catalog-authority-transition-v1.js', 'catalog-semantic-authority-transition-v1.js', @@ -227,7 +226,9 @@ const blockedRfc64Modules = [ 'inventory-v1/swm-author-inventory-sql-codec.js', 'finalized-policy-agent-precommit-v1.js', 'finalized-policy-verifier-v1.js', + 'finalized-private-placement-repair-store-v1.js', 'catalog-synchronization-evidence-v1.js', + 'finalized-swm-retirement-lifecycle-receipt-v1.js', 'finalized-vm-agent-precommit-v1.js', 'finalized-vm-composer-v1.js', 'finalized-vm-runtime-v1.js', @@ -242,6 +243,7 @@ const blockedRfc64Modules = [ 'public-catalog-current-head-discovery-v1.js', 'public-catalog-inventory-completeness-v1.js', 'public-catalog-native-reconciler-v1.js', + 'public-catalog-native-committed-head-token-v1.js', 'public-catalog-native-receiver-v1.js', 'public-catalog-native-transport-v1.js', 'public-open-catalog-scope-v1.js', diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts index ded6307451..fc31e0605e 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog-auto-publish.ts @@ -2,8 +2,9 @@ /** * Selected-CG RFC-64 SWM inventory and public-catalog authoring support. - * Finalized VM remains inventoried by the chain; confirmation retracts the - * corresponding SWM-only catalog row. + * Finalized VM remains inventoried by the chain. Public/owner-signed lanes + * retract confirmed SWM-only rows; finalized private lanes publish the + * authenticated recovery placement only after confirmation. */ import { @@ -41,8 +42,9 @@ import { } from '@origintrail-official/dkg-publisher'; import { DKGAgentBase } from './dkg-agent-base.js'; import type { DKGAgent } from './dkg-agent.js'; -import { rfc64CatalogLaneAcceptsWorkspaceHeadV1 } from - './dkg-agent-rfc64-swm-catalog-projection.js'; +import { + rfc64CatalogLaneAcceptsWorkspaceHeadV1, +} from './dkg-agent-rfc64-swm-catalog-projection.js'; import type { Rfc64CatalogSuccessorAssetInputV1, } from './dkg-agent-rfc64-catalog.js'; @@ -50,12 +52,18 @@ import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js import { maintainRfc64SwmAuthorInventoryV1, removeRfc64SwmAuthorInventoryRowV1, + type Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1, + type RemoveRfc64SwmAuthorInventoryResultV1, } from './rfc64/swm-author-inventory-producer-v1.js'; import { rfc64SwmInventoryShadowRuntimeV1, type Rfc64SwmAuthorInventoryShadowMutationResultV1, type Rfc64SwmAuthorInventoryShadowStatusV1, } from './rfc64/swm-inventory-shadow-runtime-v1.js'; +import { + snapshotRfc64FinalizedPrivatePlacementRepairV1, + type Rfc64FinalizedPrivatePlacementRepairV1, +} from './rfc64/finalized-private-placement-repair-store-v1.js'; export type { Rfc64SwmAuthorInventoryShadowMutationResultV1, @@ -181,6 +189,11 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { try { const result = await this.recordRfc64SwmAuthorInventoryShadowV1(params); if (result.status === 'applied' || result.status === 'existing') { + const lane = this.resolveRfc64CatalogAuthoringLaneV1( + params.contextGraphId, + params.subGraphName, + ); + if (lane?.projectionLifecycle === 'confirmation-gated-append') return; this.requestRfc64SwmCatalogProjectionV1({ contextGraphId: params.contextGraphId as ContextGraphIdV1, authorAddress: params.lifecycleAgentAddress.toLowerCase() as EvmAddressV1, @@ -232,11 +245,10 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { } /** - * Canonical post-confirmation observer for exact SWM-inventory removal. - * Finalized VM is already inventoried by the chain. Remove its SWM row and - * enqueue selected-catalog convergence so RFC-64 no longer advertises - * the asset as SWM-only. The irreversible publish response never waits for - * catalog signing, storage, or peer fan-out. + * Canonical post-confirmation observer. SWM-only lanes retract the pending + * row. A finalized private lane first appends the now-chain-backed placement + * to its durable recovery catalog, then removes only the pending inventory + * row. The irreversible publish response never waits for this observer. */ async observeRfc64ConfirmedVmV1( this: DKGAgent, @@ -270,11 +282,54 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { authorAddress: confirmedSeal.authorAddress, assertionCoordinate, }); + let finalizedPrivateInventoryScope: SwmAuthorInventoryScopeV1 | null = null; + try { + const lane = this.resolveRfc64CatalogAuthoringLaneV1(contextGraphId, subGraphName); + if (lane?.projectionLifecycle === 'confirmation-gated-append') { + finalizedPrivateInventoryScope = Object.freeze({ + ...lane.scopeBase, + authorAddress: confirmedSeal.authorAddress, + }) as SwmAuthorInventoryScopeV1; + } + } catch (cause) { + this.log.warn( + params.ctx, + `Confirmed ${params.publicationLabel} but RFC-64 catalog authority was unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + return; + } + // Fence every confirmed version, including confirmation-gated finalized + // private repairs, until the asset-tail repair and queued observers drain. + // Newer assertion versions use distinct fence entries and remain eligible. shadowRuntime.markVmConfirmed(assetKey, confirmedSeal.assertionVersion); + let finalizedPrivateAttempt: Promise | null = null; try { await shadowRuntime.runExclusive( assetKey, async () => { + if (finalizedPrivateInventoryScope !== null) { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + const repair = snapshotRfc64FinalizedPrivatePlacementRepairV1({ + version: 1, + contextGraphId: contextGraphId as ContextGraphIdV1, + authorAddress: confirmedSeal.authorAddress, + inventoryScope: finalizedPrivateInventoryScope, + assertionCoordinate, + assertionVersion: confirmedSeal.assertionVersion, + kaUal: confirmedSeal.kaUal, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(confirmedSeal), + }); + // This durable marker is the restart boundary: pre-confirmation + // rows have none, while every admitted post-confirmation placement + // survives a crash or transient signing/catalog failure. + await persistence.finalizedPrivatePlacementRepairs.put(repair); + finalizedPrivateAttempt = this.requestRfc64FinalizedPrivateCatalogPlacementRepairV1({ + repair, + ctx: params.ctx, + }).whenAttempted; + return; + } const result = await this.removeRfc64SwmAuthorInventoryShadowV1({ contextGraphId, subGraphName, @@ -289,6 +344,7 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { } }, ); + await finalizedPrivateAttempt; } catch (cause) { this.log.warn( params.ctx, @@ -297,6 +353,37 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { } } + /** Idempotent durable repair body owned by the catalog supervisor. */ + async repairRfc64FinalizedPrivateCatalogPlacementV1( + this: DKGAgent, + repair: Readonly, + ): Promise<'repaired' | 'already-complete'> { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + const assetKey = rfc64SwmInventoryAssetKeyV1({ + contextGraphId: repair.contextGraphId, + authorAddress: repair.authorAddress, + assertionCoordinate: repair.assertionCoordinate, + }); + let outcome: 'repaired' | 'already-complete' | null = null; + await rfc64SwmInventoryShadowRuntimeV1(this).runExclusive(assetKey, async () => { + const applied = await this.publishRfc64FinalizedPrivateCatalogPlacementV1(repair); + if (applied === null) { + await persistence.finalizedPrivatePlacementRepairs.delete(repair); + outcome = 'already-complete'; + return; + } + await this.removeRfc64SwmAuthorInventoryConfirmedRowV1({ + scope: repair.inventoryScope, + expectedRow: repair, + }); + await persistence.finalizedPrivatePlacementRepairs.delete(repair); + outcome = 'repaired'; + }); + if (outcome === null) throw new Error('RFC-64 finalized-private placement repair did not run'); + return outcome; + } + readRfc64SwmAuthorInventorySnapshotV1( this: DKGAgent, params: Readonly<{ @@ -481,7 +568,7 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { } } - /** Remove a row after VM confirmation; the chain becomes the VM inventory. */ + /** Remove one pending SWM-inventory row after its lane-specific confirmation work. */ async removeRfc64SwmAuthorInventoryShadowV1( this: DKGAgent, params: RemoveRfc64SwmAuthorInventoryShadowParamsV1, @@ -506,29 +593,14 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { ...lane.scopeBase, authorAddress: seal.authorAddress, }); - const persistence = this.rfc64PersistenceV1; - if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); - const signer = this.createRfc64CatalogAuthorSignerV1(seal.authorAddress); - const inventoryScopeDigest = computeSwmAuthorInventoryScopeDigestV1(scope); - const removed = await rfc64SwmInventoryShadowRuntimeV1(this).runScopeExclusive( - `${inventoryScopeDigest}\n${seal.authorAddress}`, - () => removeRfc64SwmAuthorInventoryRowV1( - persistence.swmAuthorInventory, - { - scope, - expectedRow: Object.freeze({ - kaUal: seal.kaUal, - assertionVersion: seal.assertionVersion, - sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), - }), - issuedAt: Date.now().toString() as TimestampMsV1, - signer: Object.freeze({ - issuer: signer.address as EvmAddressV1, - signDigest: signer.signMessage, - }), - }, - ), - ); + const removed = await this.removeRfc64SwmAuthorInventoryConfirmedRowV1({ + scope, + expectedRow: Object.freeze({ + assertionVersion: seal.assertionVersion, + kaUal: seal.kaUal, + sealDigest: computeCanonicalGraphScopedAuthorSealDigestV1(seal), + }), + }); return this.recordRfc64SwmAuthorInventoryShadowStatsV1( shadowResult( removed.status, @@ -549,6 +621,35 @@ export class Rfc64CatalogAutoPublishMethods extends DKGAgentBase { } } + /** Remove the exact confirmed row without requiring its transient AssertionSeal object. */ + async removeRfc64SwmAuthorInventoryConfirmedRowV1( + this: DKGAgent, + params: Readonly<{ + readonly scope: SwmAuthorInventoryScopeV1; + readonly expectedRow: Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1; + }>, + ): Promise { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + const signer = this.createRfc64CatalogAuthorSignerV1(params.scope.authorAddress); + const inventoryScopeDigest = computeSwmAuthorInventoryScopeDigestV1(params.scope); + return rfc64SwmInventoryShadowRuntimeV1(this).runScopeExclusive( + `${inventoryScopeDigest}\n${params.scope.authorAddress}`, + () => removeRfc64SwmAuthorInventoryRowV1( + persistence.swmAuthorInventory, + { + scope: params.scope, + expectedRow: params.expectedRow, + issuedAt: Date.now().toString() as TimestampMsV1, + signer: Object.freeze({ + issuer: signer.address as EvmAddressV1, + signDigest: signer.signMessage, + }), + }, + ), + ); + } + /** * Explicit low-level public-root catalog authoring entrypoint. This is kept * for catalog construction and the upcoming SWM producer lane; it is not a diff --git a/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts b/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts index b235a77eff..0c207a3354 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog-upsert.ts @@ -7,6 +7,7 @@ import { assertSignedAuthorCatalogHeadEnvelopeV1, assertSignedAuthorCatalogIssuerDelegationEnvelopeV1, canonicalizeCanonicalGraphScopedAuthorSealV1, + computeCanonicalGraphScopedAuthorSealDigestV1, computeAuthorCatalogScopeDigestV1, computeControlSignatureVariantDigestHex, decodeOpaqueKaBundleV1, @@ -41,6 +42,8 @@ import { resolveRfc64CatalogExecutionPlanAuthorityV1 } from import { throwIfRfc64AbortedV1 as throwIfAbortedV1, } from './rfc64/abort-v1.js'; +import type { Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1 } from + './rfc64/swm-author-inventory-producer-v1.js'; export interface UpsertConfirmedRfc64PublicRootCatalogAssetParamsV1 { readonly scope: AuthorCatalogScopeV1; @@ -109,6 +112,29 @@ interface Rfc64CatalogMutationStateV1 { } export class Rfc64CatalogUpsertMethods extends DKGAgentBase { + /** Package-internal positive proof used by crash-safe confirmed-row retirement. */ + async rfc64CatalogContainsConfirmedSwmRowV1( + this: DKGAgent, + params: Readonly<{ + readonly scope: AuthorCatalogScopeV1; + readonly expectedRow: Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1; + }>, + ): Promise { + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + const state = await this.readRfc64CatalogMutationStateV1( + persistence, + computeAuthorCatalogScopeDigestV1(params.scope), + params.scope.authorAddress, + ); + return state?.assets.some((asset) => ( + asset.seal.kaUal === params.expectedRow.kaUal + && asset.seal.assertionVersion === params.expectedRow.assertionVersion + && computeCanonicalGraphScopedAuthorSealDigestV1(asset.seal) + === params.expectedRow.sealDigest + )) ?? false; + } + /** * Own genesis creation, predecessor reconstruction, exact-set successor, * applied-head CAS, and best-effort availability announcement as one diff --git a/packages/agent/src/dkg-agent-rfc64-catalog.ts b/packages/agent/src/dkg-agent-rfc64-catalog.ts index cf9579d785..5f4720e904 100644 --- a/packages/agent/src/dkg-agent-rfc64-catalog.ts +++ b/packages/agent/src/dkg-agent-rfc64-catalog.ts @@ -86,8 +86,9 @@ import { createRfc64CatalogAppliedHeadCoordinatorV1, } from './rfc64/catalog-applied-head-coordinator-v1.js'; import type { Rfc64CatalogAppliedHeadEvidenceV1 } from - './rfc64/catalog-applied-head-evidence-v1.js'; + './rfc64/finalized-swm-retirement-lifecycle-receipt-v1.js'; import { + reduceRfc64CatalogSynchronizationEvidenceReplayV1, snapshotRfc64CatalogSynchronizationEvidenceV1, type Rfc64CatalogSynchronizationEvidenceV1, } from './rfc64/catalog-synchronization-evidence-v1.js'; @@ -948,7 +949,13 @@ export class Rfc64CatalogMethods extends DKGAgentBase { deployment, signal, ); - const observed = snapshotRfc64CatalogSynchronizationEvidenceV1(evidence); + const current = snapshotRfc64CatalogSynchronizationEvidenceV1(evidence); + const previous = this.rfc64PublicCatalogSynchronizationEvidenceV1.get( + evidence.catalogHeadDigest, + ); + const observed = previous === undefined + ? current + : reduceRfc64CatalogSynchronizationEvidenceReplayV1(previous, current); this.rfc64PublicCatalogSynchronizationEvidenceV1.set( evidence.catalogHeadDigest, observed, diff --git a/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection-supervisor.ts b/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection-supervisor.ts index 3bdfff542c..78d2c314a5 100644 --- a/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection-supervisor.ts +++ b/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection-supervisor.ts @@ -25,9 +25,12 @@ import type { import { mapWithConcurrency } from './map-with-concurrency.js'; import { Rfc64CoalescingSupervisorV1 } from './rfc64/coalescing-supervisor-v1.js'; +import type { Rfc64FinalizedPrivatePlacementRepairV1 } from + './rfc64/finalized-private-placement-repair-store-v1.js'; const MAX_CONCURRENT_REPAIRS_V1 = 4; const MAX_STATUS_ERROR_BYTES_V1 = 1024; +const FINALIZED_PRIVATE_RETRY_INTERVAL_MS_V1 = 5_000; const UTF8 = new TextEncoder(); export type Rfc64PublicCatalogAuthorRepairOutcomeV1 = @@ -73,13 +76,21 @@ interface MutableAuthorRepairStatusV1 { export interface ProjectionSupervisorStateV1 { readonly retryIntervalMs?: number; readonly repairs: MutableAuthorRepairStatusV1[]; + readonly finalizedPrivateAttemptWaiters: Map void>>; readonly ctx: OperationContext; readonly runner: Rfc64CoalescingSupervisorV1; + readonly finalizedPrivateRunner: Rfc64CoalescingSupervisorV1; pass: number; lastPassStartedAtMs: number | null; lastPassCompletedAtMs: number | null; } +export interface Rfc64FinalizedPrivatePlacementRepairRequestV1 { + readonly accepted: boolean; + /** Settles after this exact repair's first admitted attempt, independent of other work. */ + readonly whenAttempted: Promise; +} + export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { /** Seed bounded local-author projection work from selected catalog scopes. */ startRfc64SwmCatalogProjectionSupervisorV1( @@ -89,11 +100,9 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { // Same-instance restart reopens live admission even when no bootstrap // manifest exists and the first scope will arrive through SHARE. const config = this.resolveRuntimeRfc64ProjectionBootstrapConfigV1(); - if (config === undefined) return; - const partition = partitionRfc64CatalogBootstrapV1( - config, - this.config.rfc64CatalogExecutionPlan, - ); + const partition = config === undefined + ? undefined + : partitionRfc64CatalogBootstrapV1(config, this.config.rfc64CatalogExecutionPlan); const localAuthors = this.listLocalAgents().map( ({ agentAddress }) => agentAddress.toLowerCase() as EvmAddressV1, ); @@ -103,10 +112,11 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { // inventory is a cheap no-op, while an empty durable inventory must remain // discoverable so a post-VM retraction can be repaired after restart. const repairKeys = new Set(); - const repairs = partition.track2Policies.flatMap( + const repairs = (partition?.track2Policies ?? []).flatMap( ({ policyEnvelope }): MutableAuthorRepairStatusV1[] => { const contextGraphId = policyEnvelope.payload.contextGraphId as ContextGraphIdV1; - if (this.resolveRfc64CatalogAuthoringLaneV1(contextGraphId, null) === null) { + const lane = this.resolveRfc64CatalogAuthoringLaneV1(contextGraphId, null); + if (lane === null || lane.projectionLifecycle !== 'immediate-exact-set') { return []; } return localAuthors.flatMap((authorAddress) => { @@ -128,7 +138,10 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { }); }, ); - if (repairs.length === 0) return; + const hasFinalizedPrivateRepairs = ( + this.rfc64PersistenceV1?.finalizedPrivatePlacementRepairs.list().length ?? 0 + ) > 0; + if (repairs.length === 0 && !hasFinalizedPrivateRepairs) return; const existing = this.rfc64CatalogRuntimeV1.readProjectionState(); if (existing !== undefined) { if (existing.runner.closed) return; @@ -140,25 +153,22 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { existing.repairs.push(repair); } existing.runner.request(); + if (hasFinalizedPrivateRepairs) existing.finalizedPrivateRunner.request(); return; } - let state!: ProjectionSupervisorStateV1; - const runner = this.createRfc64SwmCatalogProjectionRunnerV1( - () => state, - partition.retryIntervalMs, + const retryIntervalMs = partition?.retryIntervalMs + ?? (hasFinalizedPrivateRepairs + ? FINALIZED_PRIVATE_RETRY_INTERVAL_MS_V1 + : undefined); + const state = this.createRfc64ProjectionSupervisorStateV1( + retryIntervalMs, + retryIntervalMs ?? FINALIZED_PRIVATE_RETRY_INTERVAL_MS_V1, ctx, ); - state = { - retryIntervalMs: partition.retryIntervalMs, - repairs, - ctx, - runner, - pass: 0, - lastPassStartedAtMs: null, - lastPassCompletedAtMs: null, - }; + state.repairs.push(...repairs); this.rfc64CatalogRuntimeV1.writeProjectionState(state); - runner.request(); + if (repairs.length > 0) state.runner.request(); + if (hasFinalizedPrivateRepairs) state.finalizedPrivateRunner.request(); } /** @@ -182,7 +192,8 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { // author seal and local signing capability. Startup discovery remains // restricted to registered local authors; live requests do not repeat a // registry check that can lag custodial author activation. - if (this.resolveRfc64CatalogAuthoringLaneV1(params.contextGraphId, null) === null) { + const lane = this.resolveRfc64CatalogAuthoringLaneV1(params.contextGraphId, null); + if (lane === null || lane.projectionLifecycle !== 'immediate-exact-set') { return false; } @@ -195,22 +206,11 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { config, this.config.rfc64CatalogExecutionPlan, ).retryIntervalMs; - let created!: ProjectionSupervisorStateV1; - const runner = this.createRfc64SwmCatalogProjectionRunnerV1( - () => created, + state = this.createRfc64ProjectionSupervisorStateV1( retryIntervalMs, + retryIntervalMs ?? FINALIZED_PRIVATE_RETRY_INTERVAL_MS_V1, params.ctx ?? createOperationContext('system'), ); - created = { - retryIntervalMs, - repairs: [], - ctx: params.ctx ?? createOperationContext('system'), - runner, - pass: 0, - lastPassStartedAtMs: null, - lastPassCompletedAtMs: null, - }; - state = created; this.rfc64CatalogRuntimeV1.writeProjectionState(state); } if (state.runner.closed) return false; @@ -238,13 +238,60 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { return state.runner.request(); } + /** Enqueue one already-durable chain-confirmed private placement transition. */ + requestRfc64FinalizedPrivateCatalogPlacementRepairV1( + this: DKGAgent, + params: Readonly<{ + readonly repair: Readonly; + readonly ctx?: OperationContext; + }>, + ): Rfc64FinalizedPrivatePlacementRepairRequestV1 { + const rejected = (): Rfc64FinalizedPrivatePlacementRepairRequestV1 => Object.freeze({ + accepted: false, + whenAttempted: Promise.resolve(), + }); + if (this.rfc64CatalogRuntimeV1.projectionAdmissionClosed) return rejected(); + const lane = this.resolveRfc64CatalogAuthoringLaneV1( + params.repair.contextGraphId, + null, + ); + if (lane === null || lane.projectionLifecycle !== 'confirmation-gated-append') { + return rejected(); + } + let state = this.rfc64CatalogRuntimeV1.readProjectionState(); + if (state === undefined) { + const retryIntervalMs = this.resolveRuntimeRfc64ProjectionBootstrapConfigV1() + ?.retryIntervalMs ?? FINALIZED_PRIVATE_RETRY_INTERVAL_MS_V1; + state = this.createRfc64ProjectionSupervisorStateV1( + retryIntervalMs, + retryIntervalMs, + params.ctx ?? createOperationContext('system'), + ); + this.rfc64CatalogRuntimeV1.writeProjectionState(state); + } + if (state.finalizedPrivateRunner.closed) return rejected(); + const key = finalizedPrivateRepairKeyV1(params.repair); + let settleAttempt!: () => void; + const whenAttempted = new Promise((resolve) => { settleAttempt = resolve; }); + const waiters = state.finalizedPrivateAttemptWaiters.get(key) ?? new Set<() => void>(); + waiters.add(settleAttempt); + state.finalizedPrivateAttemptWaiters.set(key, waiters); + if (!state.finalizedPrivateRunner.request()) { + waiters.delete(settleAttempt); + if (waiters.size === 0) state.finalizedPrivateAttemptWaiters.delete(key); + settleAttempt(); + return rejected(); + } + return Object.freeze({ accepted: true, whenAttempted }); + } + readRfc64SwmCatalogProjectionSupervisorStatusV1( this: DKGAgent, ): Readonly | null { const state = this.rfc64CatalogRuntimeV1.readProjectionState(); if (state === undefined) return null; return Object.freeze({ - running: state.runner.running, + running: state.runner.running || state.finalizedPrivateRunner.running, pass: state.pass, retryIntervalMs: state.retryIntervalMs ?? 0, lastPassStartedAtMs: state.lastPassStartedAtMs, @@ -257,17 +304,62 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { async whenRfc64SwmCatalogProjectionSupervisorIdleV1(this: DKGAgent): Promise { const state = this.rfc64CatalogRuntimeV1.readProjectionState(); - await state?.runner.whenIdle(); + if (state === undefined) return; + await Promise.all([state.runner.whenIdle(), state.finalizedPrivateRunner.whenIdle()]); } async closeRfc64SwmCatalogProjectionSupervisorV1(this: DKGAgent): Promise { this.rfc64CatalogRuntimeV1.closeProjectionAdmission(); const state = this.rfc64CatalogRuntimeV1.readProjectionState(); if (state === undefined) return; - await state.runner.close(); + await Promise.all([state.runner.close(), state.finalizedPrivateRunner.close()]); + for (const waiters of state.finalizedPrivateAttemptWaiters.values()) { + for (const settle of waiters) settle(); + } + state.finalizedPrivateAttemptWaiters.clear(); this.rfc64CatalogRuntimeV1.clearProjectionState(); } + private createRfc64ProjectionSupervisorStateV1( + this: DKGAgent, + retryIntervalMs: number | undefined, + finalizedPrivateRetryIntervalMs: number, + ctx: OperationContext, + ): ProjectionSupervisorStateV1 { + let state!: ProjectionSupervisorStateV1; + const runner = this.createRfc64SwmCatalogProjectionRunnerV1( + () => state, + retryIntervalMs, + ctx, + ); + const finalizedPrivateRunner = new Rfc64CoalescingSupervisorV1({ + retryIntervalMs: finalizedPrivateRetryIntervalMs, + runPass: (signal) => this.runRfc64FinalizedPrivatePlacementRepairPassV1( + state, + signal, + ), + onError: (error) => { + this.log.warn( + ctx, + `RFC-64 finalized-private repair pass failed: ${errorMessageV1(error)}`, + ); + }, + closingMessage: 'RFC-64 finalized-private placement repair closing', + }); + state = { + retryIntervalMs, + repairs: [], + finalizedPrivateAttemptWaiters: new Map(), + ctx, + runner, + finalizedPrivateRunner, + pass: 0, + lastPassStartedAtMs: null, + lastPassCompletedAtMs: null, + }; + return state; + } + private createRfc64SwmCatalogProjectionRunnerV1( this: DKGAgent, resolveState: () => ProjectionSupervisorStateV1, @@ -317,6 +409,34 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { } } + private async runRfc64FinalizedPrivatePlacementRepairPassV1( + this: DKGAgent, + state: ProjectionSupervisorStateV1, + signal: AbortSignal, + ): Promise { + const repairs = this.rfc64PersistenceV1?.finalizedPrivatePlacementRepairs.list() ?? []; + await mapWithConcurrency(repairs, MAX_CONCURRENT_REPAIRS_V1, async (repair) => { + const key = finalizedPrivateRepairKeyV1(repair); + try { + if (signal.aborted) return; + await this.repairRfc64FinalizedPrivateCatalogPlacementV1(repair); + } catch (error) { + if (!signal.aborted) { + this.log.warn( + createOperationContext('system'), + `RFC-64 finalized-private placement repair failed for ${repair.contextGraphId} / ${repair.kaUal}: ${boundedErrorV1(errorMessageV1(error))}`, + ); + } + } finally { + const waiters = state.finalizedPrivateAttemptWaiters.get(key); + if (waiters !== undefined) { + state.finalizedPrivateAttemptWaiters.delete(key); + for (const settle of waiters) settle(); + } + } + }); + } + private async reconcileRfc64LocalSwmCatalogProjectionV1( this: DKGAgent, repair: MutableAuthorRepairStatusV1, @@ -393,6 +513,18 @@ export class Rfc64SwmCatalogProjectionSupervisorMethods extends DKGAgentBase { } } +function finalizedPrivateRepairKeyV1( + repair: Readonly, +): string { + return JSON.stringify([ + repair.contextGraphId, + repair.authorAddress, + repair.kaUal, + repair.assertionVersion, + repair.sealDigest, + ]); +} + function errorMessageV1(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection.ts b/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection.ts index 2014cbbc41..639967a09d 100644 --- a/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection.ts +++ b/packages/agent/src/dkg-agent-rfc64-swm-catalog-projection.ts @@ -12,12 +12,15 @@ import { contextGraphMetaUri, encodeCanonicalCgSharedPublicRootProjectionV1, parseGraphScopedAssertionSealCandidate, + type AuthorCatalogScopeV1, type AuthorLaneScopeV1, type CatalogSealDeploymentProfileV1, + type CanonicalDeterministicUalV1, type ContextGraphIdV1, type Digest32V1, type EvmAddressV1, type NetworkIdV1, + type PositiveDecimalU64V1, type SwmAuthorInventoryRowV1, type SwmAuthorInventoryScopeV1, type TimestampMsV1, @@ -37,6 +40,7 @@ import type { } from './dkg-agent-rfc64-catalog.js'; import type { ReconcileRfc64PublicRootCatalogExactSetResultV1 } from './dkg-agent-rfc64-catalog-upsert.js'; +import type { AppliedCatalogHeadSnapshotV1 } from './rfc64/inventory-v1/index.js'; import { raceRfc64AgainstAbortV1 as raceAgainstAbortV1, throwIfRfc64AbortedV1 as throwIfAbortedV1, @@ -65,6 +69,7 @@ export interface ReconcileRfc64PublicCatalogFromSwmInventoryResultV1 interface ResolvedRfc64CatalogAuthoringLaneBaseV1 { readonly networkId: NetworkIdV1; + readonly policySourceKind: 'finalized-chain' | 'owner-signed-unregistered'; readonly service: Rfc64PublicCatalogServiceV1; readonly announcementPeers: readonly string[]; readonly catalogIssuerDelegationEffectiveAt: TimestampMsV1; @@ -75,9 +80,11 @@ interface ResolvedRfc64CatalogAuthoringLaneBaseV1 { type ResolvedRfc64CatalogAuthoringLaneV1 = | Readonly | Readonly; type Rfc64CatalogAuthoringLaneDecisionV1 = @@ -96,6 +103,7 @@ export function rfc64CatalogLaneAcceptsWorkspaceHeadV1( ? accessPolicy === 'public' : accessPolicy === 'ownerOnly' || accessPolicy === 'allowList'; } + export class Rfc64SwmCatalogProjectionMethods extends DKGAgentBase { /** Project the latest authenticated durable author inventory into its signed catalog. */ async reconcileRfc64PublicCatalogFromSwmInventoryV1( @@ -110,6 +118,84 @@ export class Rfc64SwmCatalogProjectionMethods extends DKGAgentBase { return this.reconcileRfc64PublicCatalogFromSwmInventoryLaneV1(lane, params); } + /** + * Move one chain-confirmed private placement from the pending SWM inventory + * into the durable catalog. The catalog retains prior finalized placements; + * pre-finalized rows are never projected through this lane. + */ + protected async publishRfc64FinalizedPrivateCatalogPlacementV1( + this: DKGAgent, + params: Readonly<{ + readonly contextGraphId: ContextGraphIdV1; + readonly authorAddress: EvmAddressV1; + readonly inventoryScope: SwmAuthorInventoryScopeV1; + readonly assertionCoordinate: string; + readonly assertionVersion: PositiveDecimalU64V1; + readonly kaUal: CanonicalDeterministicUalV1; + readonly sealDigest: Digest32V1; + }>, + ): Promise { + const lane = this.resolveRfc64CatalogAuthoringLaneV1(params.contextGraphId, null); + if (lane === null || lane.projectionLifecycle !== 'confirmation-gated-append') { + throw new Error('RFC-64 finalized-private placement repair lane is inactive'); + } + const currentInventoryScope = Object.freeze({ + ...lane.scopeBase, + authorAddress: params.authorAddress, + }) as SwmAuthorInventoryScopeV1; + if ( + computeSwmAuthorInventoryScopeDigestV1(currentInventoryScope) + !== computeSwmAuthorInventoryScopeDigestV1(params.inventoryScope) + ) { + throw new Error( + 'RFC-64 finalized-private placement repair conflicts with a policy transition', + ); + } + const inventoryScope = params.inventoryScope; + const persistence = this.rfc64PersistenceV1; + if (persistence === undefined) throw new Error('RFC-64 persistence is unavailable'); + const inventoryScopeDigest = computeSwmAuthorInventoryScopeDigestV1(inventoryScope); + const snapshot = persistence.swmAuthorInventory.readSwmAuthorInventorySnapshotV1( + inventoryScopeDigest, + params.authorAddress, + ); + const row = snapshot?.rows.find((candidate) => ( + candidate.assertionCoordinate === params.assertionCoordinate + && candidate.assertionVersion === params.assertionVersion + && candidate.kaUal === params.kaUal + && candidate.sealDigest === params.sealDigest + )); + const scope = Object.freeze({ + ...inventoryScope, + bucketCount: '1', + }) as AuthorCatalogScopeV1; + if (row === undefined) { + if (await this.rfc64CatalogContainsConfirmedSwmRowV1({ + scope, + expectedRow: params, + })) return null; + throw new Error( + 'RFC-64 finalized-private source row is missing without catalog publication proof', + ); + } + const asset = await this.resolveRfc64SwmInventoryCatalogAssetV1( + params.contextGraphId, + params.authorAddress, + lane, + row, + ); + lane.service.acceptedPolicySnapshotForCatalogScope(scope); + return this.upsertConfirmedRfc64PublicRootCatalogAssetV1({ + scope, + author: this.createRfc64CatalogAuthorSignerV1(params.authorAddress), + asset, + deployment: await this.resolveRfc64AutoPublishDeploymentProfileV1(lane.networkId), + peers: lane.announcementPeers, + catalogIssuerDelegationEffectiveAt: lane.catalogIssuerDelegationEffectiveAt, + catalogIssuerDelegationExpiresAt: lane.catalogIssuerDelegationExpiresAt, + }); + } + /** Canonical selected-CG admission shared by inventory and projection. */ protected resolveRfc64CatalogAuthoringLaneV1( this: DKGAgent, @@ -359,6 +445,7 @@ export class Rfc64SwmCatalogProjectionMethods extends DKGAgentBase { } const commonLane = Object.freeze({ networkId, + policySourceKind: acceptedPolicy.policy.source.kind, service, announcementPeers: selectedControl.announcementPeers, catalogIssuerDelegationEffectiveAt: @@ -379,10 +466,14 @@ export class Rfc64SwmCatalogProjectionMethods extends DKGAgentBase { ? Object.freeze({ ...commonLane, kind: 'public', + projectionLifecycle: 'immediate-exact-set', }) : Object.freeze({ ...commonLane, kind: 'private', + projectionLifecycle: acceptedPolicy.policy.source.kind === 'finalized-chain' + ? 'confirmation-gated-append' + : 'immediate-exact-set', }); return Object.freeze({ status: 'active', diff --git a/packages/agent/src/rfc64/catalog-applied-head-coordinator-v1.ts b/packages/agent/src/rfc64/catalog-applied-head-coordinator-v1.ts index 7b3cba8a5d..0d609b8305 100644 --- a/packages/agent/src/rfc64/catalog-applied-head-coordinator-v1.ts +++ b/packages/agent/src/rfc64/catalog-applied-head-coordinator-v1.ts @@ -9,34 +9,36 @@ import { import type { TripleStore } from '@origintrail-official/dkg-storage'; import { mapWithConcurrencySettled } from '../map-with-concurrency.js'; +import { + reconcileFinalizedSwmTwinFromCatalogProjection, + type FinalizedSwmTwinRetirement, +} from '../sync/requester/finalized-swm-twin-reconciliation.js'; import type { AcceptedRfc64CatalogAccessSnapshotV1 } from './catalog-access-policy-v1.js'; import type { Rfc64PublicCatalogNativeAppliedHeadLifecycleV1, Rfc64PublicCatalogNativeBeforeAppliedHeadCommitPlanV1, Rfc64PublicCatalogNativeBeforeAppliedHeadCommitHandlerV1, - Rfc64PublicCatalogNativeCommittedHeadTokenV1, Rfc64PublicCatalogNativePrimaryPrecommitHandlerV1, Rfc64PublicCatalogNativePrecommitTransactionV1, } from './public-catalog-native-receiver-v1.js'; +import type { Rfc64PublicCatalogNativeCommittedHeadTokenV1 } from + './public-catalog-native-committed-head-token-v1.js'; +import type { + Rfc64CatalogAppliedHeadEvidenceV1, + Rfc64FinalizedSwmRetirementLifecycleReceiptV2, +} from + './finalized-swm-retirement-lifecycle-receipt-v1.js'; +export type { + Rfc64FinalizedSwmRetirementLifecycleReceiptV1, + Rfc64FinalizedSwmRetirementLifecycleReceiptV2, +} from + './finalized-swm-retirement-lifecycle-receipt-v1.js'; import type { Rfc64FinalizedVmAgentPrecommitHandlerV1, Rfc64FinalizedVmAgentPrecommitTransactionV1, } from './finalized-vm-agent-precommit-v1.js'; -import type { - Rfc64CatalogAppliedHeadEvidenceV1, - Rfc64FinalizedSwmRetirementLifecycleReceiptV1, -} from './catalog-applied-head-evidence-v1.js'; -import { - reconcileFinalizedSwmTwinFromCatalogProjection, - type FinalizedSwmTwinRetirement, -} from '../sync/requester/finalized-swm-twin-reconciliation.js'; const POST_HEAD_TWIN_RECONCILIATION_CONCURRENCY_V1 = 4; - -export type { - Rfc64FinalizedSwmRetirementLifecycleReceiptV1, -} from './catalog-applied-head-evidence-v1.js'; - export interface Rfc64CatalogAppliedHeadCoordinatorOptionsV1 { readonly acceptedPolicySnapshotForCatalogScope: (scope: Readonly) => AcceptedRfc64CatalogAccessSnapshotV1; @@ -144,7 +146,7 @@ async function createFinalizedVmAppliedHeadLifecycleV1( retire: (retirement) => options.retire(retirement, ctx), }); return Object.freeze({ - kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v1', + kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2', contextGraphId: plan.catalogScope.contextGraphId, ...(plan.catalogScope.subGraphName === null ? {} @@ -155,7 +157,7 @@ async function createFinalizedVmAppliedHeadLifecycleV1( vmPostReadDigest: materialization.postReadDigest, vmMaterializationStatus: materialization.status, swmReconciliationOutcome, - }) satisfies Rfc64FinalizedSwmRetirementLifecycleReceiptV1; + }) satisfies Rfc64FinalizedSwmRetirementLifecycleReceiptV2; }, ); const retired = receipts.filter( diff --git a/packages/agent/src/rfc64/catalog-synchronization-evidence-v1.ts b/packages/agent/src/rfc64/catalog-synchronization-evidence-v1.ts index aa3bf277f6..77f838d13c 100644 --- a/packages/agent/src/rfc64/catalog-synchronization-evidence-v1.ts +++ b/packages/agent/src/rfc64/catalog-synchronization-evidence-v1.ts @@ -2,8 +2,8 @@ import type { Rfc64CatalogAppliedHeadEvidenceV1, - Rfc64FinalizedSwmRetirementLifecycleReceiptV1, -} from './catalog-applied-head-evidence-v1.js'; + Rfc64FinalizedSwmRetirementLifecycleReceiptV2, +} from './finalized-swm-retirement-lifecycle-receipt-v1.js'; import type { Rfc64PublicCatalogNativeSynchronizationEvidenceV1, } from './public-catalog-native-receiver-v1.js'; @@ -26,7 +26,7 @@ type Rfc64NativeSynchronizationEvidenceWithoutExtensionV1 = export type Rfc64CatalogSynchronizationEvidenceV1 = Readonly< Rfc64NativeSynchronizationEvidenceWithoutExtensionV1 & { readonly finalizedSwmRetirementLifecycleReceipts: - readonly Readonly[]; + readonly Readonly[]; } >; @@ -65,3 +65,58 @@ export function snapshotRfc64CatalogSynchronizationEvidenceV1( Object.freeze({ ...receipt }))), }); } + +/** + * Accumulate only the two monotonic facts proved by a benign exact-head replay. + * Every per-run field otherwise comes from the current observation so a newly + * detected integrity failure can never be hidden by older success evidence. + */ +export function reduceRfc64CatalogSynchronizationEvidenceReplayV1( + previous: Readonly, + current: Readonly, +): Rfc64CatalogSynchronizationEvidenceV1 { + if ( + previous.catalogHeadDigest !== current.catalogHeadDigest + || previous.inventoryDigest !== current.inventoryDigest + ) { + throw new TypeError('RFC-64 prior synchronization evidence belongs to a different head'); + } + const previousByUal = new Map( + previous.finalizedSwmRetirementLifecycleReceipts + .map((receipt) => [receipt.kaUal, receipt] as const), + ); + return Object.freeze({ + ...current, + finalizedSwmRetirementLifecycleReceipts: Object.freeze( + current.finalizedSwmRetirementLifecycleReceipts.map((receipt) => { + const prior = previousByUal.get(receipt.kaUal); + if (!isBenignExactHeadLifecycleReplayV1(prior, receipt)) return receipt; + return Object.freeze({ + ...receipt, + vmMaterializationStatus: 'materialized' as const, + swmReconciliationOutcome: 'retired' as const, + }); + }), + ), + }); +} + +function isBenignExactHeadLifecycleReplayV1( + previous: Readonly | undefined, + current: Readonly, +): boolean { + return previous?.vmMaterializationStatus === 'materialized' + && previous.swmReconciliationOutcome === 'retired' + && current.vmMaterializationStatus === 'existing' + && ( + current.swmReconciliationOutcome === 'retired' + || current.swmReconciliationOutcome === 'already-retired-finalized' + ) + && previous.kind === current.kind + && previous.contextGraphId === current.contextGraphId + && previous.subGraphName === current.subGraphName + && previous.kaUal === current.kaUal + && previous.assertionVersion === current.assertionVersion + && previous.vmGraphIri === current.vmGraphIri + && previous.vmPostReadDigest === current.vmPostReadDigest; +} diff --git a/packages/agent/src/rfc64/finalized-private-placement-repair-store-v1.ts b/packages/agent/src/rfc64/finalized-private-placement-repair-store-v1.ts new file mode 100644 index 0000000000..7868d8ec8f --- /dev/null +++ b/packages/agent/src/rfc64/finalized-private-placement-repair-store-v1.ts @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; + +import { + assertAssertionCoordinateV1, + assertCanonicalDeterministicUalV1, + assertCanonicalDigest, + assertCanonicalEvmAddress, + assertContextGraphIdV1, + assertSwmAuthorInventoryScopeV1, + parseCanonicalDecimalU64, + type AssertionCoordinateV1, + type CanonicalDeterministicUalV1, + type ContextGraphIdV1, + type Digest32V1, + type EvmAddressV1, + type PositiveDecimalU64V1, + type SwmAuthorInventoryScopeV1, +} from '@origintrail-official/dkg-core'; + +import type { Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1 } from + './swm-author-inventory-producer-v1.js'; + +const UTF8_ENCODER = new TextEncoder(); +const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }); + +export interface Rfc64FinalizedPrivatePlacementRepairV1 + extends Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1 { + readonly version: 1; + readonly contextGraphId: ContextGraphIdV1; + readonly authorAddress: EvmAddressV1; + /** Exact confirmation-time scope; a policy transition must not redirect recovery. */ + readonly inventoryScope: SwmAuthorInventoryScopeV1; + readonly assertionCoordinate: AssertionCoordinateV1; + readonly assertionVersion: PositiveDecimalU64V1; + readonly kaUal: CanonicalDeterministicUalV1; + readonly sealDigest: Digest32V1; +} + +/** The repair queue is implemented by the single owned inventory connection. */ +export interface Rfc64FinalizedPrivatePlacementRepairOperationsV1 { + listFinalizedPrivatePlacementRepairs(): readonly Readonly[]; + putFinalizedPrivatePlacementRepair( + repair: Readonly, + ): void; + deleteFinalizedPrivatePlacementRepair( + repair: Readonly, + ): void; +} + +export interface Rfc64FinalizedPrivatePlacementRepairStoreV1 { + list(): readonly Readonly[]; + put(repair: Readonly): Promise; + delete(repair: Readonly): Promise; +} + +export function createRfc64FinalizedPrivatePlacementRepairStoreV1( + operations: Rfc64FinalizedPrivatePlacementRepairOperationsV1, +): Rfc64FinalizedPrivatePlacementRepairStoreV1 { + return Object.freeze({ + list: () => operations.listFinalizedPrivatePlacementRepairs(), + put: async (repair: Readonly) => { + operations.putFinalizedPrivatePlacementRepair(repair); + }, + delete: async (repair: Readonly) => { + operations.deleteFinalizedPrivatePlacementRepair(repair); + }, + }); +} + +export function snapshotRfc64FinalizedPrivatePlacementRepairV1( + input: Readonly, +): Readonly { + if (input.version !== 1) throw new TypeError('RFC-64 placement repair version is invalid'); + assertContextGraphIdV1(input.contextGraphId, 'placement repair contextGraphId'); + assertCanonicalEvmAddress(input.authorAddress, 'placement repair authorAddress'); + assertSwmAuthorInventoryScopeV1(input.inventoryScope); + if (input.inventoryScope.authorAddress !== input.authorAddress) { + throw new TypeError('RFC-64 placement repair inventory scope author differs'); + } + if (input.inventoryScope.contextGraphId !== input.contextGraphId) { + throw new TypeError('RFC-64 placement repair inventory scope graph differs'); + } + assertAssertionCoordinateV1(input.assertionCoordinate, 'placement repair assertionCoordinate'); + parseCanonicalDecimalU64(input.assertionVersion, 'placement repair assertionVersion'); + if (BigInt(input.assertionVersion) < 1n) { + throw new TypeError('RFC-64 placement repair assertionVersion must be positive'); + } + const canonicalUal = assertCanonicalDeterministicUalV1(input.kaUal); + assertCanonicalDigest(input.sealDigest, 'placement repair sealDigest'); + return Object.freeze({ + version: 1, + contextGraphId: input.contextGraphId, + authorAddress: input.authorAddress, + inventoryScope: Object.freeze({ ...input.inventoryScope }), + assertionCoordinate: input.assertionCoordinate, + assertionVersion: input.assertionVersion, + kaUal: canonicalUal.ual, + sealDigest: input.sealDigest, + }); +} + +/** Canonical row bytes and digest used as the SQLite queue identity. */ +export function encodeRfc64FinalizedPrivatePlacementRepairV1( + repair: Readonly, +): Uint8Array { + return UTF8_ENCODER.encode(`${JSON.stringify(snapshotRfc64FinalizedPrivatePlacementRepairV1(repair))}\n`); +} + +export function digestRfc64FinalizedPrivatePlacementRepairV1( + repairBytes: Uint8Array, +): Uint8Array { + return new Uint8Array(createHash('sha256').update(repairBytes).digest()); +} + +export function parseRfc64FinalizedPrivatePlacementRepairV1( + bytes: Uint8Array, +): Readonly { + let parsed: unknown; + try { + parsed = JSON.parse(UTF8_DECODER.decode(bytes)); + } catch (cause) { + throw new Error('RFC-64 finalized-private placement repair is not valid JSON', { cause }); + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('RFC-64 finalized-private placement repair is malformed'); + } + const value = parsed as Record; + const expectedKeys = [ + 'assertionCoordinate', 'assertionVersion', 'authorAddress', 'contextGraphId', + 'inventoryScope', 'kaUal', 'sealDigest', 'version', + ]; + if (Object.keys(value).sort().join('\n') !== expectedKeys.join('\n')) { + throw new Error('RFC-64 finalized-private placement repair has unknown fields'); + } + return snapshotRfc64FinalizedPrivatePlacementRepairV1( + value as unknown as Rfc64FinalizedPrivatePlacementRepairV1, + ); +} diff --git a/packages/agent/src/rfc64/catalog-applied-head-evidence-v1.ts b/packages/agent/src/rfc64/finalized-swm-retirement-lifecycle-receipt-v1.ts similarity index 63% rename from packages/agent/src/rfc64/catalog-applied-head-evidence-v1.ts rename to packages/agent/src/rfc64/finalized-swm-retirement-lifecycle-receipt-v1.ts index 93a3eaedb4..95cf35b6f4 100644 --- a/packages/agent/src/rfc64/catalog-applied-head-evidence-v1.ts +++ b/packages/agent/src/rfc64/finalized-swm-retirement-lifecycle-receipt-v1.ts @@ -5,7 +5,7 @@ import type { Digest32V1 } from '@origintrail-official/dkg-core'; import type { FinalizedSwmTwinReconciliationOutcome } from '../sync/requester/finalized-swm-twin-reconciliation.js'; import type { Rfc64PublicCatalogNativeCommittedHeadTokenV1 } from - './public-catalog-native-receiver-v1.js'; + './public-catalog-native-committed-head-token-v1.js'; /** * Explicit per-KA proof of the only safe finalized-twin lifecycle: @@ -14,6 +14,22 @@ import type { Rfc64PublicCatalogNativeCommittedHeadTokenV1 } from */ export interface Rfc64FinalizedSwmRetirementLifecycleReceiptV1 { readonly kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v1'; + readonly catalogHeadDigest: Digest32V1; + readonly inventoryDigest: Digest32V1; + readonly committedHead: Readonly; + readonly contextGraphId: string; + readonly subGraphName?: string; + readonly kaUal: string; + readonly assertionVersion: string; + readonly vmGraphIri: string; + readonly vmPostReadDigest: Digest32V1; + readonly vmMaterializationStatus: 'materialized' | 'existing'; + readonly swmReconciliationOutcome: FinalizedSwmTwinReconciliationOutcome; +} + +/** Normalized receipt emitted after the v1 compatibility contract. */ +export interface Rfc64FinalizedSwmRetirementLifecycleReceiptV2 { + readonly kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2'; readonly contextGraphId: string; readonly subGraphName?: string; readonly kaUal: string; @@ -29,5 +45,5 @@ export interface Rfc64CatalogAppliedHeadEvidenceV1 { readonly kind: 'rfc64-catalog-applied-head-evidence-v1'; readonly committedHead: Readonly; readonly finalizedSwmRetirementLifecycleReceipts: - readonly Readonly[]; + readonly Readonly[]; } diff --git a/packages/agent/src/rfc64/inventory-v1/candidate.ts b/packages/agent/src/rfc64/inventory-v1/candidate.ts index c15c725067..c7209c37ce 100644 --- a/packages/agent/src/rfc64/inventory-v1/candidate.ts +++ b/packages/agent/src/rfc64/inventory-v1/candidate.ts @@ -70,6 +70,14 @@ import { prepareSwmAuthorInventoryCommitV1, } from './swm-author-inventory-commit-plan.js'; import { SwmAuthorInventoryPersistenceV1 } from './swm-author-inventory-persistence.js'; +import { + digestRfc64FinalizedPrivatePlacementRepairV1, + encodeRfc64FinalizedPrivatePlacementRepairV1, + parseRfc64FinalizedPrivatePlacementRepairV1, + snapshotRfc64FinalizedPrivatePlacementRepairV1, + type Rfc64FinalizedPrivatePlacementRepairV1, + type Rfc64FinalizedPrivatePlacementRepairOperationsV1, +} from '../finalized-private-placement-repair-store-v1.js'; import type { CompareAndSwapSwmAuthorInventoryInputV1, SwmAuthorInventoryCasResultV1, @@ -257,7 +265,8 @@ export interface Rfc64SwmAuthorInventoryOperationsV1 { } export interface Rfc64InventoryV1CandidateApi - extends Rfc64SwmAuthorInventoryOperationsV1 { + extends Rfc64SwmAuthorInventoryOperationsV1, + Rfc64FinalizedPrivatePlacementRepairOperationsV1 { purgeNextStartupStaleCandidateBatch(): CandidateSessionGcBatchResultV1; createCandidateSession(): CandidateSessionV1; putVerifiedCandidateBucket(load: VerifiedCandidateBucketLoadV1): CandidateBucketPutResultV1; @@ -1119,6 +1128,127 @@ export class CandidateInventoryV1 implements Rfc64InventoryV1CandidateApi { } } + listFinalizedPrivatePlacementRepairs(): readonly Readonly[] { + this.assertOpen(); + return this.readTransaction(() => { + const query = this.prepare(INVENTORY_V1_STATEMENT_SQL.listFinalizedPrivatePlacementRepairs); + const rows = this.statement(() => query.all() as SqlRowV1[]); + return Object.freeze(rows.map((row) => this.decodeFinalizedPrivatePlacementRepair(row))); + }); + } + + putFinalizedPrivatePlacementRepair( + input: Readonly, + ): void { + this.assertOpen(); + const repair = snapshotRfc64FinalizedPrivatePlacementRepairV1(input); + const bytes = encodeRfc64FinalizedPrivatePlacementRepairV1(repair); + const digest = digestRfc64FinalizedPrivatePlacementRepairV1(bytes); + const repairJson = new TextDecoder().decode(bytes); + this.writeTransaction('put finalized-private placement repair', () => { + const statement = this.prepare( + INVENTORY_V1_STATEMENT_SQL.insertFinalizedPrivatePlacementRepair, + ); + const result = this.statement(() => statement.run({ repairDigest: digest, repairJson })); + if (Number(result.changes) === 0) { + const existing = this.readFinalizedPrivatePlacementRepairRow(digest); + if (existing?.repair_json !== repairJson) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'finalized-private placement repair digest is not bound to its bytes', + ); + } + } + }, { + resolve: () => this.readFinalizedPrivatePlacementRepairRow(digest) === null + ? 'not-committed' : 'committed', + retry: () => { + const statement = this.prepare( + INVENTORY_V1_STATEMENT_SQL.insertFinalizedPrivatePlacementRepair, + ); + this.statement(() => statement.run({ repairDigest: digest, repairJson })); + }, + }); + } + + deleteFinalizedPrivatePlacementRepair( + input: Readonly, + ): void { + this.assertOpen(); + const repair = snapshotRfc64FinalizedPrivatePlacementRepairV1(input); + const bytes = encodeRfc64FinalizedPrivatePlacementRepairV1(repair); + const digest = digestRfc64FinalizedPrivatePlacementRepairV1(bytes); + const repairJson = new TextDecoder().decode(bytes); + this.writeTransaction('delete finalized-private placement repair', () => { + const current = this.readFinalizedPrivatePlacementRepairRow(digest); + if (current === null) return; + if (current.repair_json !== repairJson) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'finalized-private placement repair changed before deletion', + ); + } + const statement = this.prepare( + INVENTORY_V1_STATEMENT_SQL.deleteFinalizedPrivatePlacementRepair, + ); + const result = this.statement(() => statement.run({ repairDigest: digest, repairJson })); + if (Number(result.changes) !== 1) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'finalized-private placement repair deletion did not remove exactly one row', + ); + } + }, { + resolve: () => this.readFinalizedPrivatePlacementRepairRow(digest) === null + ? 'committed' : 'not-committed', + retry: () => { + const statement = this.prepare( + INVENTORY_V1_STATEMENT_SQL.deleteFinalizedPrivatePlacementRepair, + ); + this.statement(() => statement.run({ repairDigest: digest, repairJson })); + }, + }); + } + + private readFinalizedPrivatePlacementRepairRow( + digest: Uint8Array, + ): SqlRowV1 | null { + const query = this.prepare( + `SELECT repair_digest, repair_json + FROM rfc64_finalized_private_placement_repairs_v1 + WHERE repair_digest = :repairDigest;`, + ); + return (this.statement(() => query.get({ repairDigest: digest })) as SqlRowV1 | undefined) ?? null; + } + + private decodeFinalizedPrivatePlacementRepair( + row: SqlRowV1, + ): Readonly { + if (!(row.repair_digest instanceof Uint8Array) + || row.repair_digest.byteLength !== 32 + || typeof row.repair_json !== 'string') { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'finalized-private placement repair row has invalid storage types', + ); + } + const bytes = new TextEncoder().encode(row.repair_json); + const repair = parseRfc64FinalizedPrivatePlacementRepairV1(bytes); + if (!sqlBlobsEqualV1( + row.repair_digest, + digestRfc64FinalizedPrivatePlacementRepairV1(bytes), + ) || !sqlBlobsEqualV1( + bytes, + encodeRfc64FinalizedPrivatePlacementRepairV1(repair), + )) { + throw new InventoryV1CandidateError( + 'candidate-database-corrupt', + 'finalized-private placement repair row is not canonical or digest-bound', + ); + } + return repair; + } + private assertCandidateHeadBinding( candidate: CandidateBucketRowSnapshotV1, signedHead: SignedAuthorCatalogHeadEnvelopeV1, diff --git a/packages/agent/src/rfc64/inventory-v1/open.ts b/packages/agent/src/rfc64/inventory-v1/open.ts index 4480a3984d..37c771ded3 100644 --- a/packages/agent/src/rfc64/inventory-v1/open.ts +++ b/packages/agent/src/rfc64/inventory-v1/open.ts @@ -47,10 +47,13 @@ import { INVENTORY_V1_LEGACY_USER_VERSION, INVENTORY_V1_MIGRATE_V1_TO_V2_SQL, INVENTORY_V1_MIGRATE_V2_TO_V3_SQL, - INVENTORY_V1_USER_OBJECTS, + INVENTORY_V1_MIGRATE_V3_TO_REPAIR_QUEUE_SQL, INVENTORY_V1_USER_VERSION, INVENTORY_V1_V2_USER_OBJECTS, INVENTORY_V1_V2_USER_VERSION, + INVENTORY_V1_V3_USER_VERSION, + INVENTORY_V1_V3_USER_OBJECTS, + INVENTORY_V1_REPAIR_QUEUE_USER_OBJECTS, normalizeInventoryV1SchemaSql, } from './sql.js'; import { @@ -520,6 +523,25 @@ class InventoryV1Foundation implements Rfc64InventoryV1Foundation { return this.#candidate.compareAndSwapSwmAuthorInventoryV1(input); } + listFinalizedPrivatePlacementRepairs() { + this.requireOpen(); + return this.#candidate.listFinalizedPrivatePlacementRepairs(); + } + + putFinalizedPrivatePlacementRepair(repair: Parameters< + Rfc64InventoryV1CandidateApi['putFinalizedPrivatePlacementRepair'] + >[0]): void { + this.requireOpen(); + this.#candidate.putFinalizedPrivatePlacementRepair(repair); + } + + deleteFinalizedPrivatePlacementRepair(repair: Parameters< + Rfc64InventoryV1CandidateApi['deleteFinalizedPrivatePlacementRepair'] + >[0]): void { + this.requireOpen(); + this.#candidate.deleteFinalizedPrivatePlacementRepair(repair); + } + private requireOpen(): DatabaseSyncV1 { if (this.#database === null) { throw new InventoryV1OpenError('database-closed', 'inventory database is closed'); @@ -1163,7 +1185,7 @@ function isFreshIdentity(identity: DatabaseIdentityV1): boolean { function schemaMatches( objects: DatabaseIdentityV1['userObjects'], - expectedObjects: Readonly> = INVENTORY_V1_USER_OBJECTS, + expectedObjects: Readonly> = INVENTORY_V1_REPAIR_QUEUE_USER_OBJECTS, ): boolean { if (objects.length !== Object.keys(expectedObjects).length) return false; return objects.every((object) => { @@ -1196,13 +1218,22 @@ const INVENTORY_SCHEMA_MIGRATIONS_V1: readonly InventorySchemaMigrationV1[] = Ob }), Object.freeze({ fromVersion: INVENTORY_V1_V2_USER_VERSION, - toVersion: INVENTORY_V1_USER_VERSION, + toVersion: INVENTORY_V1_V3_USER_VERSION, fromLabel: 'v2', toLabel: 'v3', fromObjects: INVENTORY_V1_V2_USER_OBJECTS, - toObjects: INVENTORY_V1_USER_OBJECTS, + toObjects: INVENTORY_V1_V3_USER_OBJECTS, sql: INVENTORY_V1_MIGRATE_V2_TO_V3_SQL, }), + Object.freeze({ + fromVersion: INVENTORY_V1_V3_USER_VERSION, + toVersion: INVENTORY_V1_USER_VERSION, + fromLabel: 'v3', + toLabel: 'v4', + fromObjects: INVENTORY_V1_V3_USER_OBJECTS, + toObjects: INVENTORY_V1_REPAIR_QUEUE_USER_OBJECTS, + sql: INVENTORY_V1_MIGRATE_V3_TO_REPAIR_QUEUE_SQL, + }), ]); type InventorySchemaVersionClassV1 = diff --git a/packages/agent/src/rfc64/inventory-v1/sql.ts b/packages/agent/src/rfc64/inventory-v1/sql.ts index e73ca0ff66..950f42cb95 100644 --- a/packages/agent/src/rfc64/inventory-v1/sql.ts +++ b/packages/agent/src/rfc64/inventory-v1/sql.ts @@ -10,7 +10,8 @@ import { export const INVENTORY_V1_APPLICATION_ID = 0x444b3634; export const INVENTORY_V1_LEGACY_USER_VERSION = 1; export const INVENTORY_V1_V2_USER_VERSION = 2; -export const INVENTORY_V1_USER_VERSION = 3; +export const INVENTORY_V1_V3_USER_VERSION = 3; +export const INVENTORY_V1_USER_VERSION = 4; export const INVENTORY_V1_RELATIVE_PATH = `${RFC64_PERSISTENCE_ROOT_RELATIVE_PATH_V1}/${RFC64_INVENTORY_DATABASE_FILENAME_V1}`; export const INVENTORY_V1_DIRECTORY_MODE = RFC64_SECURE_DIRECTORY_MODE_V1; @@ -423,6 +424,18 @@ CREATE TABLE rfc64_swm_author_inventory_rows_v1 ( ) ON DELETE CASCADE ) WITHOUT ROWID, STRICT`; +/** Durable finalized-private placement work owned by the inventory database. */ +export const INVENTORY_V1_FINALIZED_PRIVATE_PLACEMENT_REPAIRS_TABLE_SQL = ` +CREATE TABLE rfc64_finalized_private_placement_repairs_v1 ( + repair_digest BLOB NOT NULL CHECK ( + typeof(repair_digest) = 'blob' AND length(repair_digest) = 32 + ), + repair_json TEXT NOT NULL CHECK ( + typeof(repair_json) = 'text' AND length(repair_json) > 0 AND length(repair_json) <= 8192 + ), + PRIMARY KEY (repair_digest) +) WITHOUT ROWID, STRICT`; + export const INVENTORY_V1_LEGACY_DDL = [ INVENTORY_V1_LOADS_TABLE_SQL, INVENTORY_V1_ROWS_TABLE_SQL, @@ -433,6 +446,7 @@ export const INVENTORY_V1_DDL = [ INVENTORY_V1_APPLIED_HEADS_TABLE_SQL, INVENTORY_V1_SWM_AUTHOR_HEADS_TABLE_SQL, INVENTORY_V1_SWM_AUTHOR_ROWS_TABLE_SQL, + INVENTORY_V1_FINALIZED_PRIVATE_PLACEMENT_REPAIRS_TABLE_SQL, ].join(';\n\n').concat(';'); export const INVENTORY_V1_LEGACY_USER_OBJECTS: Readonly> = Object.freeze({ @@ -447,7 +461,7 @@ export const INVENTORY_V1_V2_USER_OBJECTS: Readonly> = Ob ), }); -export const INVENTORY_V1_USER_OBJECTS: Readonly> = Object.freeze({ +export const INVENTORY_V1_V3_USER_OBJECTS: Readonly> = Object.freeze({ ...INVENTORY_V1_V2_USER_OBJECTS, rfc64_swm_author_inventory_heads_v1: normalizeInventoryV1SchemaSql( INVENTORY_V1_SWM_AUTHOR_HEADS_TABLE_SQL, @@ -457,6 +471,15 @@ export const INVENTORY_V1_USER_OBJECTS: Readonly> = Objec ), }); +export const INVENTORY_V1_REPAIR_QUEUE_USER_OBJECTS: Readonly> = Object.freeze({ + ...INVENTORY_V1_V3_USER_OBJECTS, + rfc64_finalized_private_placement_repairs_v1: normalizeInventoryV1SchemaSql( + INVENTORY_V1_FINALIZED_PRIVATE_PLACEMENT_REPAIRS_TABLE_SQL, + ), +}); + +export const INVENTORY_V1_USER_OBJECTS = INVENTORY_V1_REPAIR_QUEUE_USER_OBJECTS; + export const INVENTORY_V1_MIGRATE_V1_TO_V2_SQL = ` ${INVENTORY_V1_APPLIED_HEADS_TABLE_SQL}; PRAGMA user_version = ${INVENTORY_V1_V2_USER_VERSION};`; @@ -464,6 +487,10 @@ PRAGMA user_version = ${INVENTORY_V1_V2_USER_VERSION};`; export const INVENTORY_V1_MIGRATE_V2_TO_V3_SQL = ` ${INVENTORY_V1_SWM_AUTHOR_HEADS_TABLE_SQL}; ${INVENTORY_V1_SWM_AUTHOR_ROWS_TABLE_SQL}; +PRAGMA user_version = ${INVENTORY_V1_V3_USER_VERSION};`; + +export const INVENTORY_V1_MIGRATE_V3_TO_REPAIR_QUEUE_SQL = ` +${INVENTORY_V1_FINALIZED_PRIVATE_PLACEMENT_REPAIRS_TABLE_SQL}; PRAGMA user_version = ${INVENTORY_V1_USER_VERSION};`; export function normalizeInventoryV1SchemaSql(sql: string): string { diff --git a/packages/agent/src/rfc64/inventory-v1/statements.ts b/packages/agent/src/rfc64/inventory-v1/statements.ts index bd150a30c9..0a5ea6e99e 100644 --- a/packages/agent/src/rfc64/inventory-v1/statements.ts +++ b/packages/agent/src/rfc64/inventory-v1/statements.ts @@ -346,6 +346,17 @@ DELETE FROM rfc64_swm_author_inventory_rows_v1 WHERE inventory_scope_digest = :scope AND author_address = :author AND ka_ual = :kaUal;`, + listFinalizedPrivatePlacementRepairs: ` +SELECT repair_digest, repair_json +FROM rfc64_finalized_private_placement_repairs_v1 +ORDER BY repair_digest;`, + insertFinalizedPrivatePlacementRepair: ` +INSERT INTO rfc64_finalized_private_placement_repairs_v1 (repair_digest, repair_json) +VALUES (:repairDigest, :repairJson) +ON CONFLICT (repair_digest) DO NOTHING;`, + deleteFinalizedPrivatePlacementRepair: ` +DELETE FROM rfc64_finalized_private_placement_repairs_v1 +WHERE repair_digest = :repairDigest AND repair_json = :repairJson;`, }); export type InventoryV1StatementKey = keyof typeof INVENTORY_V1_STATEMENT_SQL; @@ -377,6 +388,9 @@ export const INVENTORY_V1_STATEMENT_IDS = Object.freeze({ updateSwmAuthorHeadCas: 'rfc64.swm-author-inventory.head.cas-update.v1', upsertSwmAuthorRow: 'rfc64.swm-author-inventory.row.upsert.v1', deleteSwmAuthorRow: 'rfc64.swm-author-inventory.row.delete.v1', + listFinalizedPrivatePlacementRepairs: 'rfc64.finalized-private-placement-repair.list.v1', + insertFinalizedPrivatePlacementRepair: 'rfc64.finalized-private-placement-repair.insert.v1', + deleteFinalizedPrivatePlacementRepair: 'rfc64.finalized-private-placement-repair.delete.v1', } as const satisfies Readonly>); export type InventoryV1StatementId = @@ -398,6 +412,7 @@ export const INVENTORY_V1_PERSISTENT_READ_STATEMENT_KEYS = Object.freeze([ 'listAppliedHeads', 'getSwmAuthorHead', 'getSwmAuthorRows', + 'listFinalizedPrivatePlacementRepairs', ] as const satisfies readonly InventoryV1StatementKey[]); export const INVENTORY_V1_PLAN_STATEMENT_KEYS = Object.freeze([ @@ -410,4 +425,6 @@ export const INVENTORY_V1_PLAN_STATEMENT_KEYS = Object.freeze([ 'updateSwmAuthorHeadCas', 'upsertSwmAuthorRow', 'deleteSwmAuthorRow', + 'insertFinalizedPrivatePlacementRepair', + 'deleteFinalizedPrivatePlacementRepair', ] as const satisfies readonly InventoryV1StatementKey[]); diff --git a/packages/agent/src/rfc64/persistence-v1.ts b/packages/agent/src/rfc64/persistence-v1.ts index f658601ee6..7e8fb2c38a 100644 --- a/packages/agent/src/rfc64/persistence-v1.ts +++ b/packages/agent/src/rfc64/persistence-v1.ts @@ -17,7 +17,13 @@ import { } from './ka-bundle-store-v1.js'; import { openRfc64KaBundleStoreForOwnedPersistenceRootV1 } from './ka-bundle-store-v1-internal.js'; import { resolveRfc64PersistenceRootV1 } from './persistence-layout-v1.js'; -import { getRfc64PersistenceRootOwnershipForInventoryV1 } from './persistence-root-ownership-v1-internal.js'; +import { getRfc64PersistenceRootOwnershipForInventoryV1 } from + './persistence-root-ownership-v1-internal.js'; +import { + createRfc64FinalizedPrivatePlacementRepairStoreV1, + type Rfc64FinalizedPrivatePlacementRepairV1, + type Rfc64FinalizedPrivatePlacementRepairStoreV1, +} from './finalized-private-placement-repair-store-v1.js'; export interface OpenRfc64PersistenceOptionsV1 { /** Yield after each non-terminal fixed-size startup purge batch. */ @@ -31,6 +37,8 @@ export interface Rfc64PersistenceV1 { readonly inventory: Rfc64InventoryV1OperationsV1; /** Feature-owned SWM-only live-set persistence capability. */ readonly swmAuthorInventory: Rfc64SwmAuthorInventoryOperationsV1; + /** Durable post-confirmation work that must survive catalog delivery failures. */ + readonly finalizedPrivatePlacementRepairs: Rfc64FinalizedPrivatePlacementRepairStoreV1; /** Non-owning cache operations; lifecycle methods remain private to this owner. */ readonly controlObjects: Rfc64ControlObjectOperationsV1; /** Durable content-addressed opaque KA bundles served by the native catalog transport. */ @@ -48,6 +56,7 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { readonly #ownedKaBundleStore: Rfc64KaBundleStoreV1; readonly inventory: Rfc64InventoryV1OperationsV1; readonly swmAuthorInventory: Rfc64SwmAuthorInventoryOperationsV1; + readonly finalizedPrivatePlacementRepairs: Rfc64FinalizedPrivatePlacementRepairStoreV1; readonly controlObjects: Rfc64ControlObjectOperationsV1; readonly kaBundles: Rfc64KaBundleOperationsV1; @@ -56,6 +65,7 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { ownedInventory: Rfc64InventoryV1Foundation, ownedControlObjectStore: Rfc64ControlObjectStoreV1, ownedKaBundleStore: Rfc64KaBundleStoreV1, + finalizedPrivatePlacementRepairs: Rfc64FinalizedPrivatePlacementRepairStoreV1, ) { this.#ownedInventory = ownedInventory; this.#ownedControlObjectStore = ownedControlObjectStore; @@ -68,6 +78,22 @@ class OwnedRfc64PersistenceV1 implements Rfc64PersistenceV1 { ownedInventory, () => this.requireOpen(), ); + this.finalizedPrivatePlacementRepairs = Object.freeze({ + list: () => { + this.requireOpen(); + return finalizedPrivatePlacementRepairs.list(); + }, + put: async (repair: Readonly) => { + this.requireOpen(); + await finalizedPrivatePlacementRepairs.put(repair); + this.requireOpen(); + }, + delete: async (repair: Readonly) => { + this.requireOpen(); + await finalizedPrivatePlacementRepairs.delete(repair); + this.requireOpen(); + }, + }); this.controlObjects = createControlObjectOperationsView(ownedControlObjectStore); this.kaBundles = createKaBundleOperationsView(ownedKaBundleStore); } @@ -157,11 +183,14 @@ export async function openRfc64PersistenceV1( const ownership = getRfc64PersistenceRootOwnershipForInventoryV1(inventory); controlObjectStore = await openRfc64ControlObjectStoreForOwnedPersistenceRootV1(ownership); kaBundleStore = await openRfc64KaBundleStoreForOwnedPersistenceRootV1(ownership); + const finalizedPrivatePlacementRepairs = + createRfc64FinalizedPrivatePlacementRepairStoreV1(inventory); return new OwnedRfc64PersistenceV1( rootPath, inventory, controlObjectStore, kaBundleStore, + finalizedPrivatePlacementRepairs, ); } catch (cause) { const failures: unknown[] = [cause]; diff --git a/packages/agent/src/rfc64/public-catalog-native-committed-head-token-v1.ts b/packages/agent/src/rfc64/public-catalog-native-committed-head-token-v1.ts new file mode 100644 index 0000000000..5c0cf7fec9 --- /dev/null +++ b/packages/agent/src/rfc64/public-catalog-native-committed-head-token-v1.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 + +import type { Digest32V1 } from '@origintrail-official/dkg-core'; + +/** Exact durable-head evidence created only after the receiver's durable post-read. */ +export interface Rfc64PublicCatalogNativeCommittedHeadTokenV1 { + readonly kind: 'rfc64-public-catalog-native-committed-head-token-v1'; + readonly catalogHeadDigest: Digest32V1; + readonly inventoryDigest: Digest32V1; +} diff --git a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts index 7d1d82204c..767f7b4a82 100644 --- a/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts +++ b/packages/agent/src/rfc64/public-catalog-native-receiver-v1.ts @@ -75,6 +75,10 @@ import { workspacePublicQuadsDigest } from '@origintrail-official/dkg-publisher' import { ethers } from 'ethers'; import { parseNQuads } from '../dkg-agent-utils.js'; +import type { Rfc64PublicCatalogNativeCommittedHeadTokenV1 } from + './public-catalog-native-committed-head-token-v1.js'; +export type { Rfc64PublicCatalogNativeCommittedHeadTokenV1 } from + './public-catalog-native-committed-head-token-v1.js'; import { assertRfc64ExactIssuerSignatureProofV1 } from './catalog-transport-wire-v1-internal.js'; import { readVerifiedAuthorCatalogRowAuthorshipV1, @@ -223,16 +227,6 @@ export interface Rfc64PublicCatalogNativePrecommitTransactionV1 { rollback(cause?: unknown): Promise; } -/** - * Exact durable-head evidence created by the receiver only after the target - * head and inventory digest survive their post-commit read. - */ -export interface Rfc64PublicCatalogNativeCommittedHeadTokenV1 { - readonly kind: 'rfc64-public-catalog-native-committed-head-token-v1'; - readonly catalogHeadDigest: Digest32V1; - readonly inventoryDigest: Digest32V1; -} - /** Neutral operation-owned extension returned only after post-head work settles. */ export type Rfc64PublicCatalogNativePostHeadExtensionV1 = Readonly>; diff --git a/packages/agent/src/rfc64/swm-author-inventory-producer-v1.ts b/packages/agent/src/rfc64/swm-author-inventory-producer-v1.ts index 8b2813d921..fad9b08658 100644 --- a/packages/agent/src/rfc64/swm-author-inventory-producer-v1.ts +++ b/packages/agent/src/rfc64/swm-author-inventory-producer-v1.ts @@ -77,15 +77,18 @@ export interface MaintainRfc64SwmAuthorInventoryResultV1 { export interface RemoveRfc64SwmAuthorInventoryInputV1 { readonly scope: SwmAuthorInventoryScopeV1; /** Exact SWM row identity that reached VM; a newer row for the UAL is preserved. */ - readonly expectedRow: Readonly>; + readonly expectedRow: Readonly; readonly issuedAt: TimestampMsV1; readonly signer: Rfc64ControlEnvelopeEip191SignerV1; readonly maxCasAttempts?: number; } +/** Canonical identity required to remove exactly one confirmed SWM row. */ +export type Rfc64ConfirmedSwmAuthorInventoryRowIdentityV1 = Pick< + SwmAuthorInventoryRowV1, + 'kaUal' | 'assertionVersion' | 'sealDigest' +>; + export interface RemoveRfc64SwmAuthorInventoryResultV1 { readonly status: 'applied' | 'absent'; readonly attempts: number; @@ -353,10 +356,7 @@ function prepareInput(input: MaintainRfc64SwmAuthorInventoryInputV1): Readonly<{ function prepareRemovalInput(input: RemoveRfc64SwmAuthorInventoryInputV1): Readonly<{ scope: Readonly; - expectedRow: Readonly>; + expectedRow: Readonly; issuedAt: TimestampMsV1; signer: Rfc64ControlEnvelopeEip191SignerV1; maxCasAttempts: number; diff --git a/packages/agent/test/rfc64-catalog-synchronization-evidence-v1.test.ts b/packages/agent/test/rfc64-catalog-synchronization-evidence-v1.test.ts index f97e7bdd10..3c5400e2b9 100644 --- a/packages/agent/test/rfc64-catalog-synchronization-evidence-v1.test.ts +++ b/packages/agent/test/rfc64-catalog-synchronization-evidence-v1.test.ts @@ -3,10 +3,13 @@ import { describe, expect, it } from 'vitest'; import type { Digest32V1 } from '@origintrail-official/dkg-core'; -import { snapshotRfc64CatalogSynchronizationEvidenceV1 } from +import { + reduceRfc64CatalogSynchronizationEvidenceReplayV1, + snapshotRfc64CatalogSynchronizationEvidenceV1, +} from '../src/rfc64/catalog-synchronization-evidence-v1.js'; -import type { Rfc64FinalizedSwmRetirementLifecycleReceiptV1 } from - '../src/rfc64/catalog-applied-head-evidence-v1.js'; +import type { Rfc64FinalizedSwmRetirementLifecycleReceiptV2 } from + '../src/rfc64/finalized-swm-retirement-lifecycle-receipt-v1.js'; const digest = (byte: string): Digest32V1 => `0x${byte.repeat(32)}` as Digest32V1; @@ -14,7 +17,7 @@ function receipt( kaUal = 'did:dkg:otp:20430/0x1111111111111111111111111111111111111111/1', ) { return { - kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v1' as const, + kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2' as const, contextGraphId: 'private-evidence-v1', kaUal, assertionVersion: '1', @@ -22,11 +25,11 @@ function receipt( vmPostReadDigest: digest('33'), vmMaterializationStatus: 'materialized' as const, swmReconciliationOutcome: 'retired' as const, - } satisfies Rfc64FinalizedSwmRetirementLifecycleReceiptV1; + } satisfies Rfc64FinalizedSwmRetirementLifecycleReceiptV2; } function evidence( - receipts: readonly Rfc64FinalizedSwmRetirementLifecycleReceiptV1[], + receipts: readonly Rfc64FinalizedSwmRetirementLifecycleReceiptV2[], committedCatalogHeadDigest = digest('11'), ) { return { @@ -75,4 +78,102 @@ describe('RFC-64 catalog synchronization evidence', () => { evidence([same, receipt()]), )).toThrow('duplicates receipt'); }); + + it('preserves original materialization proof across an exact-head replay', () => { + const first = snapshotRfc64CatalogSynchronizationEvidenceV1(evidence([receipt()])); + const replayReceipt = { + ...receipt(), + vmMaterializationStatus: 'existing' as const, + swmReconciliationOutcome: 'already-retired-finalized' as const, + }; + const replay = reduceRfc64CatalogSynchronizationEvidenceReplayV1( + first, + snapshotRfc64CatalogSynchronizationEvidenceV1( + { ...evidence([replayReceipt]), appliedHeadStatus: 'existing' as const }, + ), + ); + + expect(replay.appliedHeadStatus).toBe('existing'); + expect(replay.finalizedSwmRetirementLifecycleReceipts).toEqual([ + expect.objectContaining({ + vmMaterializationStatus: 'materialized', + swmReconciliationOutcome: 'retired', + }), + ]); + }); + + it('preserves original materialization proof when replay re-retires the SWM twin', () => { + const originalReceipt = receipt(); + const replayReceipt = { + ...originalReceipt, + vmMaterializationStatus: 'existing' as const, + swmReconciliationOutcome: 'retired' as const, + }; + const replay = reduceRfc64CatalogSynchronizationEvidenceReplayV1( + snapshotRfc64CatalogSynchronizationEvidenceV1(evidence([originalReceipt])), + snapshotRfc64CatalogSynchronizationEvidenceV1({ + ...evidence([replayReceipt]), + appliedHeadStatus: 'existing' as const, + }), + ); + + expect(replay.finalizedSwmRetirementLifecycleReceipts).toEqual([{ + ...originalReceipt, + vmMaterializationStatus: 'materialized', + swmReconciliationOutcome: 'retired', + }]); + }); + + it.each([ + ['content mismatch', { swmReconciliationOutcome: 'content-mismatch' as const }], + ['VM change', { swmReconciliationOutcome: 'vm-changed' as const }], + ['head-version mismatch', { swmReconciliationOutcome: 'head-version-mismatch' as const }], + ['VM metadata mismatch', { swmReconciliationOutcome: 'vm-metadata-mismatch' as const }], + ['receipt metadata mismatch', { assertionVersion: '2' }], + ['post-read mismatch', { vmPostReadDigest: digest('55') }], + ])('keeps current %s evidence visible instead of retaining stale success', (_label, change) => { + const first = snapshotRfc64CatalogSynchronizationEvidenceV1(evidence([receipt()])); + const currentReceipt = { + ...receipt(), + vmMaterializationStatus: 'existing' as const, + swmReconciliationOutcome: 'already-retired-finalized' as const, + ...change, + }; + const current = snapshotRfc64CatalogSynchronizationEvidenceV1({ + ...evidence([currentReceipt]), + appliedHeadStatus: 'existing' as const, + }); + + const reduced = reduceRfc64CatalogSynchronizationEvidenceReplayV1(first, current); + + expect(reduced.finalizedSwmRetirementLifecycleReceipts).toEqual([currentReceipt]); + }); + + it('rejects replay accumulation across different synchronization heads', () => { + const first = snapshotRfc64CatalogSynchronizationEvidenceV1(evidence([receipt()])); + const otherHead = digest('66'); + const current = snapshotRfc64CatalogSynchronizationEvidenceV1({ + ...evidence([receipt()], otherHead), + catalogHeadDigest: otherHead, + appliedHeadStatus: 'existing' as const, + }); + + expect(() => reduceRfc64CatalogSynchronizationEvidenceReplayV1(first, current)) + .toThrow('belongs to a different head'); + }); + + it('uses a repaired rematerialization receipt as the new current proof', () => { + const first = snapshotRfc64CatalogSynchronizationEvidenceV1(evidence([receipt()])); + const rematerializedReceipt = { + ...receipt(), + vmPostReadDigest: digest('77'), + }; + const current = snapshotRfc64CatalogSynchronizationEvidenceV1({ + ...evidence([rematerializedReceipt]), + appliedHeadStatus: 'existing' as const, + }); + + expect(reduceRfc64CatalogSynchronizationEvidenceReplayV1(first, current) + .finalizedSwmRetirementLifecycleReceipts).toEqual([rematerializedReceipt]); + }); }); diff --git a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts index 276b464cc2..312d6b24c4 100644 --- a/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts +++ b/packages/agent/test/rfc64-dkg-agent-native-wiring.integration.test.ts @@ -42,6 +42,7 @@ import { GraphManager, OxigraphStore, quadsToNQuads, + readExactGraphPaged, readSwmMaterializationWitness, writeSwmMaterializationWitness, type Quad, @@ -325,6 +326,7 @@ interface SeedSignedSwmWorkspaceParamsV1 { readonly shareOperationId: string; readonly kaNumber: bigint; readonly accessPolicy: 'public' | 'ownerOnly' | 'allowList'; + readonly allowedPeers?: readonly string[]; readonly publicQuads?: readonly Quad[]; } @@ -374,6 +376,7 @@ async function seedSignedSwmWorkspaceV1( privateTripleCount: Number(canonicalSeal.privateTripleCount), publisherPeerId: agent.peerId, accessPolicy: params.accessPolicy, + ...(params.allowedPeers === undefined ? {} : { allowedPeers: params.allowedPeers }), agentAddress: AUTHOR, timestamp: new Date(canonicalSeal.assertionFinalizedAt), }); @@ -1121,13 +1124,56 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', peers: [providerPeerId], })); + const allowListCoordinate = 'ordinary-private-allow-list-swm'; + const allowListOperationId = 'ordinary-private-allow-list-swm-operation'; + const { seal: allowListSeal, assertionUri: allowListAssertionUri } = + await seedSignedSwmWorkspaceV1(author, { + contextGraphId: CONTEXT_GRAPH_ID, + shareOperationId: allowListOperationId, + assertionCoordinate: allowListCoordinate, + kaNumber: 24n, + accessPolicy: 'allowList', + allowedPeers: [providerPeerId], + }); + await author.afterDurableSwmPromotionV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: allowListCoordinate, + lifecycleAgentAddress: AUTHOR, + shareOperationId: allowListOperationId, + ctx: createOperationContext('share'), + }); + await author.awaitInFlightRfc64SwmInventoryObserversV1(); + await author.whenRfc64SwmCatalogProjectionSupervisorIdleV1(); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ + head: { payload: { totalRows: '2' } }, + rows: expect.arrayContaining([ + expect.objectContaining({ + assertionCoordinate: allowListCoordinate, + shareOperationId: allowListOperationId, + }), + ]), + }); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toMatchObject({ catalogVersion: '2', inventoryRowCount: '2' }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + expect(provider.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toMatchObject({ catalogVersion: '2', inventoryRowCount: '2' }); + expect(announce.mock.calls.at(-1)?.[0]).toMatchObject({ peers: [providerPeerId] }); + const mismatchedCoordinate = 'public-share-under-private-policy'; const mismatchedOperationId = 'public-share-under-private-policy-operation'; await seedSignedSwmWorkspaceV1(author, { contextGraphId: CONTEXT_GRAPH_ID, shareOperationId: mismatchedOperationId, assertionCoordinate: mismatchedCoordinate, - kaNumber: 24n, + kaNumber: 25n, accessPolicy: 'public', }); await expect(author.recordRfc64SwmAuthorInventoryShadowV1({ @@ -1139,7 +1185,7 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', expect(author.readRfc64SwmAuthorInventorySnapshotV1({ inventoryScopeDigest, authorAddress: AUTHOR, - })?.rows).toHaveLength(1); + })?.rows).toHaveLength(2); const announcementCountBeforeVm = announce.mock.calls.length; await author.observeRfc64ConfirmedVmV1({ @@ -1155,13 +1201,13 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', inventoryScopeDigest, authorAddress: AUTHOR, })).toMatchObject({ - head: { payload: { totalRows: '0' } }, - rows: [], + head: { payload: { totalRows: '1' } }, + rows: [expect.objectContaining({ assertionCoordinate: allowListCoordinate })], }); expect(author.readRfc64AppliedCatalogHeadV1({ catalogScopeDigest: catalogScopeDigest(), authorAddress: AUTHOR, - })).toMatchObject({ catalogVersion: '2', inventoryRowCount: '0' }); + })).toMatchObject({ catalogVersion: '3', inventoryRowCount: '1' }); await provider.whenRfc64PublicCatalogReceiverIdleV1(); expect(provider.readRfc64AppliedCatalogHeadV1({ catalogScopeDigest: catalogScopeDigest(), @@ -1171,10 +1217,32 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', catalogScopeDigest: catalogScopeDigest(), authorAddress: AUTHOR, })?.currentCatalogHeadDigest, - catalogVersion: '2', - inventoryRowCount: '0', + catalogVersion: '3', + inventoryRowCount: '1', }); - expect(announce).toHaveBeenCalledTimes(announcementCountBeforeVm + 1); + await author.observeRfc64ConfirmedVmV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate: allowListCoordinate, + seal: allowListSeal, + assertionUri: allowListAssertionUri, + ctx: createOperationContext('publish'), + publicationLabel: 'publish', + }); + await author.whenRfc64SwmCatalogProjectionSupervisorIdleV1(); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '0' } }, rows: [] }); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toMatchObject({ catalogVersion: '4', inventoryRowCount: '0' }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + expect(provider.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: catalogScopeDigest(), + authorAddress: AUTHOR, + })).toMatchObject({ catalogVersion: '4', inventoryRowCount: '0' }); + expect(announce).toHaveBeenCalledTimes(announcementCountBeforeVm + 2); expect(announce.mock.calls.at(-1)?.[0]).toMatchObject({ peers: [providerPeerId], }); @@ -4435,6 +4503,367 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', })).toBeNull(); }, 60_000); + it('publishes finalized private recovery placement only after chain confirmation', async () => { + const nameHash = ethers.keccak256(ethers.toUtf8Bytes(CONTEXT_GRAPH_ID)).toLowerCase(); + const kaNumber = 39n; + const finalizedAsset = Object.freeze({ + assertionRoot: ASSERTION_ROOT, + assertionVersion: '1', + authorAddress: AUTHOR, + kaId: ((BigInt(AUTHOR) << 96n) | kaNumber).toString(), + publisherAddress: AUTHOR, + }); + const fixture = Object.freeze({ + accessPolicy: 1 as const, + active: true, + assertedAtChainId: NATIVE_DEPLOYMENT.assertedAtChainId, + assertedAtKav10Address: KAV10, + knowledgeAssetStorageAddress: KA_STORAGE, + assets: Object.freeze([finalizedAsset]), + blockHash: FINALIZED_BLOCK_HASH, + blockNumberQuantity: '0x7c', + contextGraphStorageAddress: CONTEXT_GRAPH_STORAGE, + nameHash: nameHash as Digest32V1, + networkId: NETWORK_ID, + onChainContextGraphId: ON_CHAIN_CONTEXT_GRAPH_ID, + ownerAddress: AUTHOR, + publishPolicy: 0 as const, + } satisfies FinalizedVmLoopbackFixtureConfigV1); + const rpc = createFinalizedVmLoopbackRpcV1(fixture); + const rpcServer = await rpcHarness.start((call, response) => { + try { + sendJsonRpcResult(response, call, rpc.respond(call.method, call.params)); + } catch (cause) { + sendJsonRpcError( + response, + call, + -32602, + cause instanceof Error ? cause.message : String(cause), + ); + } + }); + const adapter = new FinalizedVmLoopbackMockChainAdapterV1(fixture); + await adapter.createOnChainContextGraph({ accessPolicy: 1, publishPolicy: 0, nameHash }); + + const policy: ContextGraphPolicyV1 = { + ...finalizedPublicCatalogPolicy({ publishPolicy: 0, publishAuthority: AUTHOR }), + accessPolicy: 1, + }; + const policyEnvelope = { + issuer: CONTEXT_GRAPH_STORAGE, + objectType: CONTEXT_GRAPH_POLICY_OBJECT_TYPE_V1, + payload: policy, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedContextGraphPolicyEnvelopeV1; + const policyDigest = computeContextGraphPolicyObjectDigestV1(policyEnvelope); + const roster = privateCatalogRoster(policy, policyDigest); + const rosterEnvelope = { + issuer: CONTEXT_GRAPH_STORAGE, + objectType: MEMBER_ROSTER_OBJECT_TYPE_V1, + payload: roster, + signatureEvidence: { kind: 'none' }, + signatureSuite: 'eip191-personal-sign-digest-v1', + } as UnsignedMemberRosterEnvelopeV1; + + const peerAddresses = new Map(); + const provider = await startNativeAgentWithOptions({ + name: 'finalized-private-auto-publish-provider', + networkIdentityChainId: NETWORK_ID, + accessPolicyAuthority: { + localAgentAddress: AUTHOR, + resolveRemoteAgentAddress: async (peerId) => peerAddresses.get(peerId) ?? null, + }, + finalizedRuntime: { rpcUrl: rpcServer.url, chainAdapter: adapter }, + }); + provider.acceptRfc64CatalogAccessSnapshotV1({ policy, policyDigest, roster }); + const authorDataDir = await mkdtemp(join(tmpdir(), 'dkg-rfc64-finalized-private-repair-')); + tempDirs.push(authorDataDir); + const authorPersistentStorePath = join(authorDataDir, 'oxigraph'); + const catalogActivation = { + enabled: true, + deploymentProfile: NATIVE_DEPLOYMENT, + accessPolicyAuthority: { + localAgentAddress: AUTHOR, + peerAgentBindings: [{ peerId: provider.peerId, agentAddress: AUTHOR }], + }, + autoPublish: { + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + bootstrap: { + retryIntervalMs: 60_000, + acceptedPolicies: [{ + policyEnvelope, + rosterEnvelope, + targets: [{ authorAddress: AUTHOR, providers: [provider.peerId] }], + completeSwmProviders: [provider.peerId], + }], + }, + }; + let author = await startNativeAgentWithOptions({ + name: 'finalized-private-auto-publish-author', + existingDataDir: authorDataDir, + persistentStorePath: authorPersistentStorePath, + catalogActivation, + beforeStart: (agent) => { + vi.spyOn(agent, 'getCustodialAgentPrivateKey').mockReturnValue( + AUTHOR_WALLET.privateKey, + ); + }, + }); + peerAddresses.set(author.peerId, AUTHOR); + await connectBothWays(author, provider); + let announce = vi.spyOn(author, 'announceRfc64PublicCatalogHeadV1'); + const assertionCoordinate = 'finalized-private-auto-publish'; + const shareOperationId = 'finalized-private-auto-publish-operation'; + const { seal, assertionUri } = await seedSignedSwmWorkspaceV1(author, { + contextGraphId: CONTEXT_GRAPH_ID, + shareOperationId, + assertionCoordinate, + kaNumber, + accessPolicy: 'ownerOnly', + }); + await author.afterDurableSwmPromotionV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate, + lifecycleAgentAddress: AUTHOR, + shareOperationId, + ctx: createOperationContext('share'), + }); + await author.awaitInFlightRfc64SwmInventoryObserversV1(); + + const scope = Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + governanceChainId: policy.governanceChainId, + governanceContractAddress: policy.governanceContractAddress, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: AUTHOR, + era: policy.era, + bucketCount: '1', + } as const); + const { bucketCount: _bucketCount, ...inventoryScope } = scope; + const inventoryScopeDigest = computeSwmAuthorInventoryScopeDigestV1(inventoryScope); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '1' } } }); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toBeNull(); + expect(announce).not.toHaveBeenCalled(); + + // A durable pre-confirmation row is intentionally not repairable. Restart + // cannot infer chain confirmation from its mere presence. + await author.stop(); + agents.splice(agents.indexOf(author), 1); + let failedPlacementAttempts = 0; + author = await startNativeAgentWithOptions({ + name: 'finalized-private-auto-publish-author-pre-confirmation-restart', + existingDataDir: authorDataDir, + persistentStorePath: authorPersistentStorePath, + catalogActivation, + beforeStart: (agent) => { + vi.spyOn(agent, 'getCustodialAgentPrivateKey').mockReturnValue( + AUTHOR_WALLET.privateKey, + ); + vi.spyOn(agent as any, 'publishRfc64FinalizedPrivateCatalogPlacementV1') + .mockImplementationOnce(async () => { + failedPlacementAttempts += 1; + throw new Error('simulated post-confirmation catalog failure'); + }); + }, + }); + peerAddresses.set(author.peerId, AUTHOR); + await connectBothWays(author, provider); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '1' } } }); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toBeNull(); + expect(failedPlacementAttempts).toBe(0); + + await author.observeRfc64ConfirmedVmV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate, + seal, + assertionUri, + ctx: createOperationContext('publish'), + publicationLabel: 'publish', + }); + // Confirmation is durable, but the injected first placement failure must + // leave the pending row intact rather than losing the transition. + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toBeNull(); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '1' } } }); + expect(failedPlacementAttempts).toBe(1); + + await author.stop(); + agents.splice(agents.indexOf(author), 1); + let releaseStartupRepair!: () => void; + let markStartupRepairEntered!: () => void; + const startupRepairGate = new Promise((resolve) => { + releaseStartupRepair = resolve; + }); + const startupRepairEntered = new Promise((resolve) => { + markStartupRepairEntered = resolve; + }); + let failedRemovalAttempts = 0; + author = await startNativeAgentWithOptions({ + name: 'finalized-private-auto-publish-author-confirmed-restart', + existingDataDir: authorDataDir, + persistentStorePath: authorPersistentStorePath, + catalogActivation, + beforeStart: (agent) => { + vi.spyOn(agent, 'getCustodialAgentPrivateKey').mockReturnValue( + AUTHOR_WALLET.privateKey, + ); + const original = (agent as any) + .publishRfc64FinalizedPrivateCatalogPlacementV1.bind(agent); + vi.spyOn(agent as any, 'publishRfc64FinalizedPrivateCatalogPlacementV1') + .mockImplementation(async (...args: unknown[]) => { + markStartupRepairEntered(); + await startupRepairGate; + return original(...args); + }); + vi.spyOn(agent, 'removeRfc64SwmAuthorInventoryConfirmedRowV1') + .mockImplementationOnce(async () => { + failedRemovalAttempts += 1; + throw new Error('simulated post-publication SWM removal failure'); + }); + }, + }); + peerAddresses.set(author.peerId, AUTHOR); + await startupRepairEntered; + await connectBothWays(author, provider); + await author.afterDurableSwmPromotionV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate, + lifecycleAgentAddress: AUTHOR, + shareOperationId, + ctx: createOperationContext('share'), + }); + announce = vi.spyOn(author, 'announceRfc64PublicCatalogHeadV1'); + releaseStartupRepair(); + await author.whenRfc64SwmCatalogProjectionSupervisorIdleV1(); + await author.awaitInFlightRfc64SwmInventoryObserversV1(); + const publishedHead = author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + }); + expect(publishedHead).toMatchObject({ catalogVersion: '1', inventoryRowCount: '1' }); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '1' } } }); + expect((author as any).rfc64PersistenceV1.finalizedPrivatePlacementRepairs.list()) + .toHaveLength(1); + expect(failedRemovalAttempts).toBe(1); + expect(announce).toHaveBeenCalledTimes(1); + + // Restart after publication. The exact catalog upsert must replay as + // existing, then the SWM row and its colocated repair queue entry can be + // removed from the same inventory persistence owner. + await author.stop(); + agents.splice(agents.indexOf(author), 1); + author = await startNativeAgentWithOptions({ + name: 'finalized-private-auto-publish-author-post-publication-restart', + existingDataDir: authorDataDir, + persistentStorePath: authorPersistentStorePath, + catalogActivation, + beforeStart: (agent) => { + vi.spyOn(agent, 'getCustodialAgentPrivateKey').mockReturnValue( + AUTHOR_WALLET.privateKey, + ); + }, + }); + peerAddresses.set(author.peerId, AUTHOR); + await author.whenRfc64SwmCatalogProjectionSupervisorIdleV1(); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toEqual(publishedHead); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '0' } }, rows: [] }); + expect((author as any).rfc64PersistenceV1.finalizedPrivatePlacementRepairs.list()) + .toHaveLength(0); + + // A final restart positively proves the row is already represented by the + // unchanged catalog head and does not replay a consumed queue entry. + await author.stop(); + agents.splice(agents.indexOf(author), 1); + author = await startNativeAgentWithOptions({ + name: 'finalized-private-auto-publish-author-post-removal-restart', + existingDataDir: authorDataDir, + persistentStorePath: authorPersistentStorePath, + catalogActivation, + beforeStart: (agent) => { + vi.spyOn(agent, 'getCustodialAgentPrivateKey').mockReturnValue( + AUTHOR_WALLET.privateKey, + ); + }, + }); + peerAddresses.set(author.peerId, AUTHOR); + await connectBothWays(author, provider); + await author.whenRfc64SwmCatalogProjectionSupervisorIdleV1(); + expect((author as any).rfc64PersistenceV1.finalizedPrivatePlacementRepairs.list()) + .toHaveLength(0); + await provider.synchronizeRfc64PublicCatalogFromProviderV1({ + remotePeerId: author.peerId, + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: policy.era, + }, + }); + await provider.whenRfc64PublicCatalogReceiverIdleV1(); + const authorHead = author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + }); + expect(authorHead).toEqual(publishedHead); + expect(authorHead).toMatchObject({ catalogVersion: '1', inventoryRowCount: '1' }); + expect(author.readRfc64SwmAuthorInventorySnapshotV1({ + inventoryScopeDigest, + authorAddress: AUTHOR, + })).toMatchObject({ head: { payload: { totalRows: '0' } }, rows: [] }); + expect(provider.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toMatchObject({ + currentCatalogHeadDigest: authorHead?.currentCatalogHeadDigest, + catalogVersion: '1', + inventoryRowCount: '1', + }); + + await author.observeRfc64ConfirmedVmV1({ + contextGraphId: CONTEXT_GRAPH_ID, + assertionCoordinate, + seal, + assertionUri, + ctx: createOperationContext('publish'), + publicationLabel: 'publish', + }); + expect(author.readRfc64AppliedCatalogHeadV1({ + catalogScopeDigest: computeAuthorCatalogScopeDigestV1(scope), + authorAddress: AUTHOR, + })).toEqual(authorHead); + }, 90_000); + it('awaits production private retirement and reports a real finalized missing-placement path', async () => { const providerAgentAddress = `0x${'91'.repeat(20)}` as EvmAddressV1; const coldAgentAddress = `0x${'92'.repeat(20)}` as EvmAddressV1; @@ -4682,6 +5111,7 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', }, beforeStart: async (agent) => { preexistingTwin = await seedPreexistingFinalizedTwinV1(agent, finalizedSeal); + await agent.store.dropGraph(preexistingTwin.vmGraph); const clearPublishedKnowledgeAssetSwm = agent.publisher.clearPublishedKnowledgeAssetSwm.bind(agent.publisher); vi.spyOn(agent.publisher, 'clearPublishedKnowledgeAssetSwm') @@ -4734,16 +5164,26 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', authorAddress: AUTHOR, }); expect(appliedFinalizedHead).not.toBeNull(); - expect(authorizedCold.readRfc64PublicCatalogSynchronizationEvidenceV1( - successor.headObjectDigest, - )?.finalizedSwmRetirementLifecycleReceipts).toMatchObject([{ - kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v1', + const finalizedVmPostRead = await readExactGraphPaged( + authorizedCold.store, + preexistingTwin.vmGraph, + { expectedQuadCount: PROJECTION_QUADS.length, outputGraph: '' }, + ); + const finalizedVmPostReadDigest = ethers.keccak256(ethers.concat([ + ethers.toUtf8Bytes('OT-RFC-64:finalized-vm-post-read:v1\0'), + ethers.toUtf8Bytes(quadsToNQuads(finalizedVmPostRead)), + ])).toLowerCase(); + expect(finalizedVmPostReadDigest).toBe(FINALIZED_VM_POST_READ_DIGEST); + const originalLifecycleEvidence = authorizedCold + .readRfc64PublicCatalogSynchronizationEvidenceV1(successor.headObjectDigest); + expect(originalLifecycleEvidence?.finalizedSwmRetirementLifecycleReceipts).toMatchObject([{ + kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v2', contextGraphId: CONTEXT_GRAPH_ID, kaUal: finalizedSeal.kaUal, assertionVersion: finalizedSeal.assertionVersion, vmGraphIri: preexistingTwin.vmGraph, - vmPostReadDigest: FINALIZED_VM_POST_READ_DIGEST, - vmMaterializationStatus: 'existing', + vmPostReadDigest: finalizedVmPostReadDigest, + vmMaterializationStatus: 'materialized', swmReconciliationOutcome: 'retired', }]); expect(await authorizedCold.store.countQuads(preexistingTwin.vmGraph)) @@ -4754,6 +5194,31 @@ ordinaryNativeWiringDescribe('RFC-64 DKGAgent production native catalog wiring', preexistingTwin.swmGraph, preexistingTwin.publicQuadsDigest, )).toBe(false); + await authorizedCold.synchronizeRfc64PublicCatalogFromProviderV1({ + remotePeerId: provider.peerId, + scope: { + networkId: NETWORK_ID, + contextGraphId: CONTEXT_GRAPH_ID, + subGraphName: null, + authorAddress: AUTHOR, + catalogEra: policy.era, + }, + }); + expect(authorizedCold.readRfc64PublicCatalogSynchronizationEvidenceV1( + successor.headObjectDigest, + )).toMatchObject({ + appliedHeadStatus: 'existing', + finalizedSwmRetirementLifecycleReceipts: [{ + kaUal: finalizedSeal.kaUal, + vmMaterializationStatus: 'materialized', + swmReconciliationOutcome: 'retired', + }], + }); + expect(originalLifecycleEvidence?.finalizedSwmRetirementLifecycleReceipts[0]) + .toMatchObject({ + vmMaterializationStatus: 'materialized', + swmReconciliationOutcome: 'retired', + }); expect(authorizedCold.readRfc64PublicCatalogBootstrapStatusV1()?.targets[0]) .toMatchObject({ outcome: 'applied', providerPeerId: provider.peerId }); diff --git a/packages/agent/test/rfc64-finalized-private-placement-repair-store-v1.test.ts b/packages/agent/test/rfc64-finalized-private-placement-repair-store-v1.test.ts new file mode 100644 index 0000000000..71163c6192 --- /dev/null +++ b/packages/agent/test/rfc64-finalized-private-placement-repair-store-v1.test.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; +import type { + AssertionCoordinateV1, + CanonicalDeterministicUalV1, + ContextGraphIdV1, + Digest32V1, + EvmAddressV1, + PositiveDecimalU64V1, + SwmAuthorInventoryScopeV1, +} from '@origintrail-official/dkg-core'; + +import { createRfc64FinalizedPrivatePlacementRepairStoreV1 } from + '../src/rfc64/finalized-private-placement-repair-store-v1.js'; +import { openInventoryV1 } from '../src/rfc64/inventory-v1/index.js'; + +const roots: string[] = []; +const inventories: Array>> = []; +const repair = Object.freeze({ + version: 1 as const, + contextGraphId: 'finalized-private-repair' as ContextGraphIdV1, + authorAddress: `0x${'11'.repeat(20)}` as EvmAddressV1, + inventoryScope: Object.freeze({ + networkId: 'testnet' as const, + contextGraphId: 'finalized-private-repair' as ContextGraphIdV1, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + authorAddress: `0x${'11'.repeat(20)}` as EvmAddressV1, + subGraphName: null, + era: '1' as const, + }) satisfies SwmAuthorInventoryScopeV1, + assertionCoordinate: 'asset-1' as AssertionCoordinateV1, + assertionVersion: '1' as PositiveDecimalU64V1, + kaUal: `did:dkg:otp:20430/0x${'11'.repeat(20)}/1` as CanonicalDeterministicUalV1, + sealDigest: `0x${'22'.repeat(32)}` as Digest32V1, +}); + +afterEach(async () => { + for (const inventory of inventories.splice(0)) inventory.close(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true }))); +}); + +async function openStore(dataDir: string) { + const inventory = await openInventoryV1(dataDir); + inventories.push(inventory); + return createRfc64FinalizedPrivatePlacementRepairStoreV1(inventory); +} + +describe('RFC-64 finalized-private placement repair store', () => { + it('durably retains confirmation in the inventory database until exact completion', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'rfc64-private-placement-repair-')); + roots.push(dataDir); + const first = await openStore(dataDir); + await first.put(repair); + await first.put(repair); + expect(first.list()).toEqual([repair]); + + const inventory = inventories.pop(); + inventory?.close(); + const restarted = await openStore(dataDir); + expect(restarted.list()).toEqual([repair]); + await restarted.delete(repair); + expect(restarted.list()).toEqual([]); + }); + + it('keeps the repair queue co-located with SQLite and creates no parallel directory', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'rfc64-private-placement-layout-')); + roots.push(dataDir); + const store = await openStore(dataDir); + await store.put(repair); + const persistenceRoot = join(dataDir, 'rfc64-sync'); + await expect(readdir(persistenceRoot)).resolves.not.toContain( + 'finalized-private-placement-repairs-v1', + ); + expect(store.list()).toEqual([repair]); + }); +}); diff --git a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts index 37b4bd597e..2ac18ddbe5 100644 --- a/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts +++ b/packages/agent/test/rfc64-inventory-v1-lifecycle.test.ts @@ -24,6 +24,15 @@ import { dirname, join, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { + AssertionCoordinateV1, + CanonicalDeterministicUalV1, + ContextGraphIdV1, + Digest32V1, + EvmAddressV1, + PositiveDecimalU64V1, + SwmAuthorInventoryScopeV1, +} from '@origintrail-official/dkg-core'; import { INVENTORY_V1_APPLICATION_ID, @@ -34,6 +43,8 @@ import { INVENTORY_V1_USER_OBJECTS, INVENTORY_V1_USER_VERSION, INVENTORY_V1_V2_USER_VERSION, + INVENTORY_V1_V3_USER_OBJECTS, + INVENTORY_V1_V3_USER_VERSION, INVENTORY_V1_POSIX_QUARANTINE_CAPABILITY, InventoryV1OpenError, normalizeInventoryV1SchemaSql, @@ -684,6 +695,100 @@ describe.runIf(process.platform !== 'win32')('RFC-64 inventory v1 SQLite lifecyc } }); + it('migrates an exact V3 database without losing data and opens an empty repair queue', async () => { + const dataDirectory = temporaryDataDirectory(); + const path = databasePath(dataDirectory); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const session = Buffer.alloc(32, 1); + const catalogScope = Buffer.alloc(32, 2); + const author = Buffer.alloc(20, 3); + const targetHead = Buffer.alloc(32, 4); + const inventoryScope = Buffer.alloc(32, 5); + const one = u64be(1n); + const sixteen = u64be(16n); + const chunkSize = u64be(262_144n); + const kaUal = `did:dkg:otp:20430/0x${'03'.repeat(20)}/1`; + const v3 = new DatabaseSync(path); + v3.exec([ + `PRAGMA application_id = ${INVENTORY_V1_APPLICATION_ID}`, + 'PRAGMA journal_mode = WAL', + ...Object.values(INVENTORY_V1_V3_USER_OBJECTS), + `PRAGMA user_version = ${INVENTORY_V1_V3_USER_VERSION}`, + ].join(';\n')); + v3.prepare('INSERT INTO rfc64_candidate_bucket_loads_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?)') + .run( + session, catalogScope, author, targetHead, null, one, one, Buffer.alloc(8), + Buffer.alloc(32, 6), one, sixteen, + ); + v3.prepare('INSERT INTO rfc64_candidate_bucket_rows_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)') + .run( + session, catalogScope, author, targetHead, Buffer.alloc(8), Buffer.alloc(32, 7), + Buffer.alloc(32, 8), 'migration-coordinate', one, 'cg-shared-v1', + Buffer.alloc(32, 9), Buffer.alloc(32, 10), 'dkg-ka-bundle-v1', sixteen, + chunkSize, one, Buffer.alloc(32, 11), Buffer.alloc(32, 12), Buffer.alloc(32, 13), + ); + v3.prepare('INSERT INTO rfc64_applied_catalog_heads_v1 VALUES (?,?,?,?,?,?)') + .run(catalogScope, author, targetHead, Buffer.alloc(32, 14), one, one); + v3.prepare('INSERT INTO rfc64_swm_author_inventory_heads_v1 VALUES (?,?,?,?,?,?,?,?,?,?)') + .run( + inventoryScope, author, Buffer.alloc(32, 15), one, one, Buffer.alloc(32, 16), + Buffer.from('signed-head'), null, 'upsert', kaUal, + ); + v3.prepare('INSERT INTO rfc64_swm_author_inventory_rows_v1 VALUES (?,?,?,?,?,?,?,?,?,?,?,?)') + .run( + inventoryScope, author, kaUal, 'migration-coordinate', one, + 'migration-share', Buffer.alloc(32, 17), one, one, Buffer.alloc(32, 18), one, null, + ); + v3.close(); + chmodSync(path, 0o600); + + const repair = Object.freeze({ + version: 1 as const, + contextGraphId: 'migration-context' as ContextGraphIdV1, + authorAddress: `0x${'11'.repeat(20)}` as EvmAddressV1, + inventoryScope: Object.freeze({ + networkId: 'testnet' as const, + contextGraphId: 'migration-context' as ContextGraphIdV1, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + authorAddress: `0x${'11'.repeat(20)}` as EvmAddressV1, + subGraphName: null, + era: '1' as const, + }) satisfies SwmAuthorInventoryScopeV1, + assertionCoordinate: 'migration-repair' as AssertionCoordinateV1, + assertionVersion: '1' as PositiveDecimalU64V1, + kaUal: `did:dkg:otp:20430/0x${'11'.repeat(20)}/1` as CanonicalDeterministicUalV1, + sealDigest: `0x${'22'.repeat(32)}` as Digest32V1, + }); + const foundation = await openInventoryV1(dataDirectory); + expect(foundation.listFinalizedPrivatePlacementRepairs()).toEqual([]); + foundation.putFinalizedPrivatePlacementRepair(repair); + expect(foundation.listFinalizedPrivatePlacementRepairs()).toEqual([repair]); + foundation.deleteFinalizedPrivatePlacementRepair(repair); + expect(foundation.listFinalizedPrivatePlacementRepairs()).toEqual([]); + foundation.close(); + + const migrated = new DatabaseSync(path, { readOnly: true }); + try { + expect(pragmaInteger(migrated, 'user_version')).toBe(INVENTORY_V1_USER_VERSION); + for (const table of [ + 'rfc64_candidate_bucket_loads_v1', + 'rfc64_candidate_bucket_rows_v1', + 'rfc64_applied_catalog_heads_v1', + 'rfc64_swm_author_inventory_heads_v1', + 'rfc64_swm_author_inventory_rows_v1', + ]) { + expect(migrated.prepare(`SELECT count(*) AS count FROM ${table}`).get()?.count).toBe(1); + } + expect(migrated.prepare( + 'SELECT count(*) AS count FROM rfc64_finalized_private_placement_repairs_v1', + ).get()?.count).toBe(0); + } finally { + migrated.close(); + } + }); + it('initializes the exact DK6L lease identity and holds it for the foundation lifetime', async () => { const dataDirectory = temporaryDataDirectory(); const foundation = await openInventoryV1(dataDirectory); diff --git a/packages/agent/test/rfc64-local-catalog-repair-scheduling.integration.test.ts b/packages/agent/test/rfc64-local-catalog-repair-scheduling.integration.test.ts index f305a91d6d..29280f6128 100644 --- a/packages/agent/test/rfc64-local-catalog-repair-scheduling.integration.test.ts +++ b/packages/agent/test/rfc64-local-catalog-repair-scheduling.integration.test.ts @@ -203,7 +203,9 @@ describe('RFC-64 local SWM catalog projection repair', () => { catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, }, }); - vi.spyOn(agent as any, 'resolveRfc64CatalogAuthoringLaneV1').mockReturnValue({} as never); + vi.spyOn(agent as any, 'resolveRfc64CatalogAuthoringLaneV1').mockReturnValue({ + projectionLifecycle: 'immediate-exact-set', + } as never); let active = 0; let maxActive = 0; let call = 0; @@ -249,4 +251,118 @@ describe('RFC-64 local SWM catalog projection repair', () => { expect(reconcile).toHaveBeenCalledTimes(6); expect(maxActive).toBe(4); }, 30_000); + + it('settles a finalized-private repair without waiting for an unrelated projection', async () => { + const agent = await startRepairAgentV1({ + name: 'repair-scoped-completion', + autoPublish: { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + }); + const privateContextGraphId = + '0x1111111111111111111111111111111111111111/private-repair' as ContextGraphIdV1; + const ordinaryContextGraphId = + '0x1111111111111111111111111111111111111111/blocked-repair' as ContextGraphIdV1; + vi.spyOn(agent as any, 'resolveRfc64CatalogAuthoringLaneV1') + .mockImplementation((contextGraphId: string) => ({ + projectionLifecycle: contextGraphId === privateContextGraphId + ? 'confirmation-gated-append' + : 'immediate-exact-set', + } as never)); + let markOrdinaryEntered!: () => void; + let releaseOrdinary!: () => void; + const ordinaryEntered = new Promise((resolve) => { markOrdinaryEntered = resolve; }); + const ordinaryGate = new Promise((resolve) => { releaseOrdinary = resolve; }); + vi.spyOn(agent, 'reconcileRfc64PublicCatalogFromSwmInventoryV1') + .mockImplementation(async () => { + markOrdinaryEntered(); + await ordinaryGate; + return null; + }); + const repair = Object.freeze({ + version: 1 as const, + contextGraphId: privateContextGraphId, + authorAddress: AUTHOR, + inventoryScope: Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: privateContextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + authorAddress: AUTHOR, + subGraphName: null, + era: '1' as const, + }), + assertionCoordinate: 'private-repair' as never, + assertionVersion: '1' as const, + kaUal: `did:dkg:otp:20430/${AUTHOR}/1` as never, + sealDigest: `0x${'aa'.repeat(32)}` as Digest32V1, + }); + await (agent as any).rfc64PersistenceV1.finalizedPrivatePlacementRepairs.put(repair); + const repairAttempt = vi.spyOn(agent, 'repairRfc64FinalizedPrivateCatalogPlacementV1') + .mockResolvedValue('repaired'); + + expect(agent.requestRfc64SwmCatalogProjectionV1({ + contextGraphId: ordinaryContextGraphId, + authorAddress: AUTHOR, + })).toBe(true); + await ordinaryEntered; + const request = agent.requestRfc64FinalizedPrivateCatalogPlacementRepairV1({ repair }); + expect(request.accepted).toBe(true); + await request.whenAttempted; + expect(repairAttempt).toHaveBeenCalledWith(repair); + releaseOrdinary(); + await agent.whenRfc64SwmCatalogProjectionSupervisorIdleV1(); + }, 30_000); + + it('retains confirmation-time repair work when the accepted policy era changes', async () => { + const agent = await startRepairAgentV1({ + name: 'repair-policy-transition', + autoPublish: { + peers: [], + catalogIssuerDelegationExpiresAt: '1893456000000' as TimestampMsV1, + }, + }); + const privateContextGraphId = + '0x1111111111111111111111111111111111111111/policy-transition' as ContextGraphIdV1; + const repair = Object.freeze({ + version: 1 as const, + contextGraphId: privateContextGraphId, + authorAddress: AUTHOR, + inventoryScope: Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: privateContextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + authorAddress: AUTHOR, + subGraphName: null, + era: '1' as const, + }), + assertionCoordinate: 'policy-transition' as never, + assertionVersion: '1' as const, + kaUal: `did:dkg:otp:20430/${AUTHOR}/2` as never, + sealDigest: `0x${'bb'.repeat(32)}` as Digest32V1, + }); + const repairStore = (agent as any).rfc64PersistenceV1 + .finalizedPrivatePlacementRepairs; + await repairStore.put(repair); + vi.spyOn(agent as any, 'resolveRfc64CatalogAuthoringLaneV1').mockReturnValue({ + projectionLifecycle: 'confirmation-gated-append', + scopeBase: Object.freeze({ + networkId: NETWORK_ID, + contextGraphId: privateContextGraphId, + governanceChainId: null, + governanceContractAddress: null, + ownershipTransitionDigest: null, + subGraphName: null, + era: '2', + }), + } as never); + + await expect(agent.repairRfc64FinalizedPrivateCatalogPlacementV1(repair)) + .rejects.toThrow('conflicts with a policy transition'); + expect(repairStore.list()).toEqual([repair]); + }, 30_000); }); diff --git a/packages/agent/test/rfc64-post-head-evidence-contract.typecheck.ts b/packages/agent/test/rfc64-post-head-evidence-contract.typecheck.ts index dd12b4e523..8f1a00ac38 100644 --- a/packages/agent/test/rfc64-post-head-evidence-contract.typecheck.ts +++ b/packages/agent/test/rfc64-post-head-evidence-contract.typecheck.ts @@ -1,5 +1,9 @@ -import type { Rfc64CatalogAppliedHeadEvidenceV1 } from - '../src/rfc64/catalog-applied-head-evidence-v1.js'; +import type { + Rfc64CatalogAppliedHeadEvidenceV1, + Rfc64FinalizedSwmRetirementLifecycleReceiptV1, +} from + '../src/rfc64/finalized-swm-retirement-lifecycle-receipt-v1.js'; +import type { Digest32V1 } from '@origintrail-official/dkg-core'; import type { Rfc64CatalogSynchronizationEvidenceV1 } from '../src/rfc64/catalog-synchronization-evidence-v1.js'; import type { @@ -16,6 +20,27 @@ const incompatibleLifecycle: Rfc64PublicCatalogNativeAppliedHeadLifecycleV1< }; void incompatibleLifecycle; +declare const digest: Digest32V1; +const legacyReceiptCompatibility: Rfc64FinalizedSwmRetirementLifecycleReceiptV1 = { + kind: 'rfc64-finalized-swm-retirement-lifecycle-receipt-v1', + catalogHeadDigest: digest, + inventoryDigest: digest, + committedHead: { + kind: 'rfc64-public-catalog-native-committed-head-token-v1', + catalogHeadDigest: digest, + inventoryDigest: digest, + }, + contextGraphId: 'compatibility', + kaUal: 'did:dkg:otp:20430/0x1111111111111111111111111111111111111111/1', + assertionVersion: '1', + vmGraphIri: 'did:dkg:context-graph:compatibility/ka/1/vm', + vmPostReadDigest: digest, + vmMaterializationStatus: 'materialized', + swmReconciliationOutcome: 'retired', +}; +void legacyReceiptCompatibility.catalogHeadDigest; +void legacyReceiptCompatibility.inventoryDigest; + declare const agentEvidence: Rfc64CatalogSynchronizationEvidenceV1; // @ts-expect-error Agent-facing evidence consumes and omits the neutral receiver extension field. void agentEvidence.postAppliedHeadExtension; diff --git a/packages/agent/vitest.rfc64-unit-tests.ts b/packages/agent/vitest.rfc64-unit-tests.ts index 427abd606a..13eef7e10d 100644 --- a/packages/agent/vitest.rfc64-unit-tests.ts +++ b/packages/agent/vitest.rfc64-unit-tests.ts @@ -20,6 +20,7 @@ export const RFC64_UNIT_TESTS = [ "test/rfc64-finalized-vm-precommit-shipped-pool.test.ts", "test/rfc64-catalog-applied-head-coordinator-v1.test.ts", "test/rfc64-catalog-synchronization-evidence-v1.test.ts", + "test/rfc64-finalized-private-placement-repair-store-v1.test.ts", "test/rfc64-swm-recovery-coordinator-v1.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts",