diff --git a/packages/storage/src/adapters/sparql-http.ts b/packages/storage/src/adapters/sparql-http.ts index ae3212d73..263460616 100644 --- a/packages/storage/src/adapters/sparql-http.ts +++ b/packages/storage/src/adapters/sparql-http.ts @@ -75,7 +75,7 @@ import { type SystemRecordLaneExecutionBindingV1, } from '../system-record-materializer-v1.js'; import { createSystemRecordAtomicApplyExecutorV1 } from '../system-record-atomic-apply-executor-v1-internal.js'; -import { createSystemRecordVerifiedReplacementRegistryV1 } from '../system-record-verified-replacement-v1-internal.js'; +import { resolveOwnedSystemRecordRuntimeV1 } from '../system-record-runtime-v1-internal.js'; import { OwnedManagedHttpClient } from './managed-http-client.js'; import { rotateSystemRecordMaterializationEpochV1 } from '../system-record-materialization-epoch-v1-internal.js'; import { UnsupportedTripleStoreCapabilityError } from '../unsupported-capability-error.js'; @@ -706,14 +706,14 @@ export class SparqlHttpStore implements TripleStore { // already carries the property. if (this.systemRecordLane === undefined) { try { - // Pair issuer and consumer in one private registry, but retain only the - // consumer at this storage boundary. The issuer is intentionally not a - // store property, option, facade member, or export. B3 deliberately - // discards it, leaving this production lane default-unused; the later - // structured-verifier stack must move registry creation to its private - // composition closure and hand this boundary the SAME consumer. Until - // then every caller-authored object fails before inspection/mutation. - const { consumer } = createSystemRecordVerifiedReplacementRegistryV1(); + // Resolve the ownership-lease runtime and retain only its consumer at + // the storage boundary. Every adapter for this lease receives the same + // registry, and all authentic leases share one process-wide accountant. + // The issuer remains outside the store and has no production caller in + // this default-unused stack; the later lifecycle verifier captures it. + const { consumer } = resolveOwnedSystemRecordRuntimeV1( + this.ownershipLease, + ); const atomicExecutor = createSystemRecordAtomicApplyExecutorV1({ consumer, storeId: this, diff --git a/packages/storage/src/system-record-materializer-v1.ts b/packages/storage/src/system-record-materializer-v1.ts index b74b22a22..9e6feaab3 100644 --- a/packages/storage/src/system-record-materializer-v1.ts +++ b/packages/storage/src/system-record-materializer-v1.ts @@ -1,3 +1,5 @@ +import { types as utilTypes } from 'node:util'; + import { readManagedOxigraphOwnershipSnapshotV1, type ManagedOxigraphOwnershipLeaseV1, @@ -263,12 +265,13 @@ const NETWORK_ID_PATTERN_V1 = /^[A-Za-z0-9._:-]+$/; const MAX_NETWORK_ID_BYTES_V1 = 128; const UTF8 = new TextEncoder(); -/** Snapshot the closed activation record without invoking caller accessors or iterators. */ +/** Snapshot the closed activation record without invoking caller traps or accessors. */ const snapshotActivation = (activation: unknown): SystemRecordLaneActivationSnapshotV1 => { if ( activation === null || typeof activation !== 'object' || Array.isArray(activation) || + utilTypes.isProxy(activation) || ![Object.prototype, null].includes(Object.getPrototypeOf(activation)) ) { throw new Error('system-record lane activation must be a plain data object'); @@ -303,7 +306,7 @@ const snapshotActivation = (activation: unknown): SystemRecordLaneActivationSnap } const kinds = readDataField('kinds'); - if (!Array.isArray(kinds)) { + if (!Array.isArray(kinds) || utilTypes.isProxy(kinds)) { throw new Error('system-record lane activation kinds must be the closed [agents] tuple'); } const kindKeys = Reflect.ownKeys(kinds); diff --git a/packages/storage/src/system-record-next-state-v1-internal.ts b/packages/storage/src/system-record-next-state-v1-internal.ts index 0a90ed23f..da44c0754 100644 --- a/packages/storage/src/system-record-next-state-v1-internal.ts +++ b/packages/storage/src/system-record-next-state-v1-internal.ts @@ -51,6 +51,7 @@ import { import { assertAuthenticSystemRecordAppliedSnapshotV1, assertSystemRecordRootClaimSnapshotV1, + requiresSystemRecordSnapshotRematerializationV1, type SystemRecordAppliedSnapshotV1, } from './system-record-state-snapshot-v1-internal.js'; import { @@ -231,7 +232,8 @@ export function deriveSystemRecordActiveReplacementV1(input: { ? snapshot.ownedSubjectTable : Object.freeze([]) as OwnedSubjectTableObjectV1; - if (authority.equalHead) { + if (authority.equalHead && !(snapshot.state === 'present' + && requiresSystemRecordSnapshotRematerializationV1(snapshot))) { if (snapshot.state !== 'present') { throw new Error('equal system-record head cannot exist in absent state'); } diff --git a/packages/storage/src/system-record-reservation-gate-v1-internal.ts b/packages/storage/src/system-record-reservation-gate-v1-internal.ts new file mode 100644 index 000000000..068aa2fab --- /dev/null +++ b/packages/storage/src/system-record-reservation-gate-v1-internal.ts @@ -0,0 +1,32 @@ +import { + SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES, +} from '@origintrail-official/dkg-core/system-record-v1'; + +export interface SystemRecordRuntimeReservationGateV1 { + acquire(owner: object, bytes: number): void; + release(owner: object, bytes: number): void; +} + +/** One exact, nonqueued reservation with no partial-release state. */ +export function createSystemRecordNonQueuedReservationGateV1(): SystemRecordRuntimeReservationGateV1 { + let liveOwner: object | null = null; + let accountedBytes = 0; + return Object.freeze({ + acquire(owner: object, bytes: number): void { + if (!Number.isSafeInteger(bytes) || bytes <= 0 + || liveOwner !== null + || accountedBytes + bytes > SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES) { + throw new Error('system-record atomic transient reservation is already live'); + } + liveOwner = owner; + accountedBytes += bytes; + }, + release(owner: object, bytes: number): void { + if (liveOwner !== owner || accountedBytes !== bytes) { + throw new Error('system-record atomic transient accountant state is inconsistent'); + } + liveOwner = null; + accountedBytes = 0; + }, + }); +} diff --git a/packages/storage/src/system-record-runtime-v1-internal.ts b/packages/storage/src/system-record-runtime-v1-internal.ts new file mode 100644 index 000000000..b0aedb563 --- /dev/null +++ b/packages/storage/src/system-record-runtime-v1-internal.ts @@ -0,0 +1,50 @@ +import { + isManagedOxigraphOwnershipLeaseV1, + readManagedOxigraphOwnershipSnapshotV1, + type ManagedOxigraphOwnershipLeaseV1, +} from './managed-oxigraph-ownership-v1-internal.js'; +import { + createSystemRecordVerifiedReplacementRegistryForRuntimeV1, + type SystemRecordVerifiedReplacementRegistryV1, +} from './system-record-verified-replacement-v1-internal.js'; +import { createSystemRecordNonQueuedReservationGateV1 } from './system-record-reservation-gate-v1-internal.js'; + +/** One nonqueued process-wide gate shared by every authentic managed endpoint. */ +const PROCESS_RESERVATION_GATE = createSystemRecordNonQueuedReservationGateV1(); + +const OWNED_RUNTIMES = new WeakMap< + ManagedOxigraphOwnershipLeaseV1, + SystemRecordVerifiedReplacementRegistryV1 +>(); + +/** + * Resolve the single proof runtime bound to an authentic daemon ownership lease. + * Persisted options and structural look-alikes cannot mint this authority. + */ +export function resolveOwnedSystemRecordRuntimeV1( + lease: ManagedOxigraphOwnershipLeaseV1, +): SystemRecordVerifiedReplacementRegistryV1 { + if (!isManagedOxigraphOwnershipLeaseV1(lease)) { + throw new Error('system-record runtime requires an authentic managed Oxigraph ownership lease'); + } + const ownership = readManagedOxigraphOwnershipSnapshotV1(lease); + if (ownership?.queryEndpoint === undefined || ownership.updateEndpoint === undefined) { + throw new Error('system-record runtime requires an endpoint-bound managed Oxigraph ownership lease'); + } + const existing = OWNED_RUNTIMES.get(lease); + if (existing !== undefined) return existing; + + const runtime = createSystemRecordVerifiedReplacementRegistryForRuntimeV1({ + reservationGate: PROCESS_RESERVATION_GATE, + assertAvailable: () => { + const snapshot = readManagedOxigraphOwnershipSnapshotV1(lease); + if (!snapshot?.ready || snapshot.terminal + || snapshot.queryEndpoint !== ownership.queryEndpoint + || snapshot.updateEndpoint !== ownership.updateEndpoint) { + throw new Error('system-record runtime ownership lease is not ready'); + } + }, + }); + OWNED_RUNTIMES.set(lease, runtime); + return runtime; +} diff --git a/packages/storage/src/system-record-state-snapshot-v1-internal.ts b/packages/storage/src/system-record-state-snapshot-v1-internal.ts index c198215f4..204f075c9 100644 --- a/packages/storage/src/system-record-state-snapshot-v1-internal.ts +++ b/packages/storage/src/system-record-state-snapshot-v1-internal.ts @@ -61,7 +61,10 @@ export interface SystemRecordPresentSnapshotV1 { readonly rootClaimSet: SystemRecordRootClaimSetV1; readonly capacityState: SystemRecordCapacityStateV1; readonly receipt: SystemRecordMaterializationReceiptV1; + /** Current durable epoch read from the global epoch row. */ readonly materializationEpoch: string; + /** Epoch bound by the exact persisted applied-state/receipt tuple. */ + readonly appliedTupleEpoch: string; readonly previousReservedQuads: readonly Readonly[]; readonly expectedRootClaimQuads: readonly Readonly[]; readonly requiredAbsentReservedSubjects: readonly string[]; @@ -110,6 +113,9 @@ export function decodeSystemRecordAppliedSnapshotV1(input: { if (epoch !== owned.materializationEpoch) { throw new Error('system-record materialization epoch changed during inspection'); } + const canonicalEpochRows = epochRows.filter((quad) => ( + quad.predicate === SYSTEM_RECORD_V1_PREDICATES.materializationEpoch + )); const decodedCapacity = decodeCapacityState(networkId, capacityRows); @@ -119,9 +125,7 @@ export function decodeSystemRecordAppliedSnapshotV1(input: { } const expectedFirstRead = Object.freeze([ ...capacityRows, - ...epochRows.filter((quad) => ( - quad.predicate === SYSTEM_RECORD_V1_PREDICATES.materializationEpoch - )), + ...canonicalEpochRows, ]); assertExactQuadSet(quads, expectedFirstRead, 'absent reserved state'); return markAuthenticSnapshot(Object.freeze({ @@ -171,9 +175,10 @@ export function decodeSystemRecordAppliedSnapshotV1(input: { 'materialization receipt', )); + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState); const digests = [ [recordRows, SYSTEM_RECORD_V1_PREDICATES.appliedStateDigest, - computeSystemRecordAppliedStateDigestV1(appliedState), 'applied-state digest'], + appliedStateDigest, 'applied-state digest'], [recordRows, SYSTEM_RECORD_V1_PREDICATES.ownedSubjectTableDigest, appliedState.ownedSubjectTableDigest, 'owned-table digest'], [recordRows, SYSTEM_RECORD_V1_PREDICATES.rootClaimSetDigest, @@ -191,8 +196,12 @@ export function decodeSystemRecordAppliedSnapshotV1(input: { if (appliedState.networkId !== networkId || appliedState.stableKeyHash !== stableKeyHash || claims.networkId !== networkId || claims.stableKeyHash !== stableKeyHash || capacity.networkId !== networkId || receipt.networkId !== networkId - || receipt.stableKeyHash !== stableKeyHash || appliedState.materializationEpoch !== epoch - || receipt.materializationEpoch !== epoch) { + || receipt.stableKeyHash !== stableKeyHash + || receipt.materializationEpoch !== appliedState.materializationEpoch + || receipt.stateRevision !== appliedState.stateRevision + || receipt.appliedStateDigest !== appliedStateDigest + || receipt.headDigest !== appliedState.headDigest + || BigInt(appliedState.materializationEpoch) > BigInt(epoch)) { throw new Error('persisted system-record tuple crosses its network, key, or epoch binding'); } const canonicalTableBytes = canonicalizeOwnedSubjectTableObjectV1( @@ -228,7 +237,7 @@ export function decodeSystemRecordAppliedSnapshotV1(input: { const expectedFirstRead = Object.freeze([ ...canonical.record, ...canonical.capacity, - ...canonical.epoch, + ...canonicalEpochRows, ...canonical.receipt, ]); assertExactQuadSet(quads, expectedFirstRead, 'reserved state'); @@ -241,12 +250,19 @@ export function decodeSystemRecordAppliedSnapshotV1(input: { capacityState: capacity, receipt, materializationEpoch: epoch, + appliedTupleEpoch: appliedState.materializationEpoch, previousReservedQuads: expectedFirstRead, expectedRootClaimQuads: canonical.rootClaims, requiredAbsentReservedSubjects: Object.freeze([]), })); } +export function requiresSystemRecordSnapshotRematerializationV1( + snapshot: SystemRecordPresentSnapshotV1, +): boolean { + return snapshot.appliedTupleEpoch !== snapshot.materializationEpoch; +} + export function assertAuthenticSystemRecordAppliedSnapshotV1( value: unknown, ): asserts value is SystemRecordAppliedSnapshotV1 { diff --git a/packages/storage/src/system-record-verified-replacement-v1-internal.ts b/packages/storage/src/system-record-verified-replacement-v1-internal.ts index b85c5fae1..b42dbcc52 100644 --- a/packages/storage/src/system-record-verified-replacement-v1-internal.ts +++ b/packages/storage/src/system-record-verified-replacement-v1-internal.ts @@ -28,7 +28,6 @@ import { parseCanonicalOwnedSubjectTableObjectV1, SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES, SYSTEM_RECORD_MAX_PROJECTION_BYTES, - SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES, type AgentProfileActiveHeadObjectV1, type AgentProfileVerifiedAuthoritySummaryV1, type NetworkIdV1, @@ -36,6 +35,10 @@ import { } from '@origintrail-official/dkg-core/system-record-v1'; import type { Quad } from './triple-store.js'; +import { + createSystemRecordNonQueuedReservationGateV1, + type SystemRecordRuntimeReservationGateV1, +} from './system-record-reservation-gate-v1-internal.js'; declare const VERIFIED_REPLACEMENT_HANDLE_BRAND: unique symbol; @@ -161,6 +164,11 @@ interface RuntimeReservationV1 { recoveryOwnership?: object; } +export interface SystemRecordVerifiedReplacementRegistryDepsV1 { + readonly reservationGate: SystemRecordRuntimeReservationGateV1; + readonly assertAvailable?: () => void; +} + /** Module-private and non-enumerable by construction. Handle identity is the only lookup key. */ const REGISTERED_REPLACEMENTS = new WeakMap(); const AUTHENTIC_VERIFIED_REPLACEMENT_FACTS = new WeakSet(); @@ -563,20 +571,17 @@ function bindingsEqual( * Create one non-interchangeable issuer/consumer pair. Only the consumer half belongs * in the storage executor; only the issuer half belongs in the verifier closure. */ -export function createSystemRecordVerifiedReplacementRegistryV1(): SystemRecordVerifiedReplacementRegistryV1 { +export function createSystemRecordVerifiedReplacementRegistryForRuntimeV1( + deps: SystemRecordVerifiedReplacementRegistryDepsV1, +): SystemRecordVerifiedReplacementRegistryV1 { const registryIdentity = Object.freeze(Object.create(null) as object); - let accountedBytes = 0; - let liveAtomicReservation: RuntimeReservationV1 | null = null; + const { reservationGate } = deps; const reserveAtomic = ( admittedDeadlineMs: number, decodedBytes: number, ): RuntimeReservationV1 => { - if (liveAtomicReservation !== null - || accountedBytes + SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES - > SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES) { - throw new Error('system-record atomic transient reservation is already live'); - } + deps.assertAvailable?.(); const reservation: RuntimeReservationV1 = { registryIdentity, identity: Object.freeze(Object.create(null) as object), @@ -590,8 +595,7 @@ export function createSystemRecordVerifiedReplacementRegistryV1(): SystemRecordV }, phase: 'proof', }; - accountedBytes += reservation.bytes; - liveAtomicReservation = reservation; + reservationGate.acquire(reservation.identity, reservation.bytes); return reservation; }; @@ -599,17 +603,13 @@ export function createSystemRecordVerifiedReplacementRegistryV1(): SystemRecordV if (reservation.registryIdentity !== registryIdentity || reservation.phase === 'released') { throw new Error('system-record atomic transient reservation was already released'); } - if (liveAtomicReservation !== reservation || accountedBytes !== reservation.bytes) { - throw new Error('system-record atomic transient accountant state is inconsistent'); - } + reservationGate.release(reservation.identity, reservation.bytes); reservation.phase = 'released'; reservation.charges.decoded = 0; reservation.charges.request = 0; reservation.charges.response = 0; reservation.charges.prepared = 0; reservation.recoveryOwnership = undefined; - accountedBytes -= reservation.bytes; - liveAtomicReservation = null; }; const registeredHandle = (handle: unknown): RegisteredReplacementV1 => { @@ -875,6 +875,17 @@ export function createSystemRecordVerifiedReplacementRegistryV1(): SystemRecordV return Object.freeze({ issuer, consumer }); } +/** + * Isolated registry for storage-internal tests and pure transaction composition. + * Production code must resolve the ownership-lease runtime below so every managed + * adapter and future lifecycle verifier shares one process-wide accountant. + */ +export function createSystemRecordVerifiedReplacementRegistryV1(): SystemRecordVerifiedReplacementRegistryV1 { + return createSystemRecordVerifiedReplacementRegistryForRuntimeV1({ + reservationGate: createSystemRecordNonQueuedReservationGateV1(), + }); +} + function retainedVerifiedFactsBytes( head: AgentProfileActiveHeadObjectV1, authority: AgentProfileVerifiedAuthoritySummaryV1, diff --git a/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts b/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts index 8534aace6..4d6584348 100644 --- a/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts +++ b/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts @@ -541,6 +541,32 @@ describe('system-record lane session lifecycle V1', () => { expect(handoff.calls).toEqual([]); }); + it('rejects proxied activation records and kinds before invoking traps', async () => { + const controller = build(); + let objectTrapCalls = 0; + const activation = new Proxy({ ...ACTIVATION }, { + getPrototypeOf: () => { + objectTrapCalls += 1; + throw new Error('activation proxy trap ran'); + }, + }); + await expect(controller.open(activation)).rejects.toThrow(/plain data object/); + expect(objectTrapCalls).toBe(0); + + let kindsTrapCalls = 0; + const kinds = new Proxy(['agents'], { + ownKeys: () => { + kindsTrapCalls += 1; + throw new Error('kinds proxy trap ran'); + }, + }); + await expect(controller.open({ ...ACTIVATION, kinds } as never)).rejects.toThrow( + /closed \[agents\] tuple/, + ); + expect(kindsTrapCalls).toBe(0); + expect(handoff.calls).toEqual([]); + }); + it('rejects unknown activation fields and a non-closed kinds tuple', async () => { const controller = build(); await expect(controller.open({ ...ACTIVATION, extra: true } as never)).rejects.toThrow( diff --git a/packages/storage/test/system-record-next-state-v1.test.ts b/packages/storage/test/system-record-next-state-v1.test.ts index 23bd45a45..c0e175a67 100644 --- a/packages/storage/test/system-record-next-state-v1.test.ts +++ b/packages/storage/test/system-record-next-state-v1.test.ts @@ -169,6 +169,38 @@ describe('system-record active next-state derivation', () => { expect(() => assertAuthenticSystemRecordActiveReplacementCompleteV1(result)).not.toThrow(); }); + it('atomically rematerializes an equal head retained from a prior durable epoch', () => { + const cold = coldReady(); + const snapshot = snapshotAtPriorEpoch(cold, '12'); + const result = expectReady(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })); + + expect(snapshot).toMatchObject({ + materializationEpoch: EPOCH, + appliedTupleEpoch: '12', + appliedState: { materializationEpoch: '12', stateRevision: '1' }, + }); + expect(result.nextAppliedState).toMatchObject({ + materializationEpoch: EPOCH, + stateRevision: '2', + headDigest: cold.next.appliedState.headDigest, + }); + expect(result.next.capacityState).toMatchObject({ revision: '2', liveRecordCount: '1' }); + expect(result.previousReservedQuads).toHaveLength( + snapshot.previousReservedQuads.length + cold.next.rootClaimQuads.length, + ); + expect(result.previousReservedQuads).toEqual(expect.arrayContaining([ + ...snapshot.previousReservedQuads, + ...cold.next.rootClaimQuads, + ])); + expect(result.nextReservedQuads).not.toEqual(result.previousReservedQuads); + expect(result.success.stateRevision).toBe('2'); + expect(() => assertAuthenticSystemRecordActiveReplacementCompleteV1(result)).not.toThrow(); + }); + it('never acknowledges an equal digest whose canonical persisted tuple disagrees with the head', () => { const cold = coldReady(); const inconsistent = snapshotWithAppliedState(cold, { @@ -442,6 +474,43 @@ function snapshotFrom(value: SystemRecordActiveReplacementCompleteV1): SystemRec }); } +function snapshotAtPriorEpoch( + ready: SystemRecordActiveReplacementReadyV1, + priorEpoch: string, +): SystemRecordAppliedSnapshotV1 { + const appliedState: SystemRecordAppliedStatePresentV1 = { + ...ready.next.appliedState, + materializationEpoch: priorEpoch, + }; + const receipt: SystemRecordMaterializationReceiptV1 = { + ...ready.next.receipt, + materializationEpoch: priorEpoch, + appliedStateDigest: computeSystemRecordAppliedStateDigestV1(appliedState), + }; + const prior = buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: ready.next.headVersion, + ownedSubjectTable: ready.next.ownedSubjectTable, + rootClaimSet: ready.next.rootClaimSet, + capacityState: ready.next.capacityState, + receipt, + }); + const currentEpoch = buildSystemRecordReservedStateQuadsV1({ + appliedState: ready.next.appliedState, + headVersion: ready.next.headVersion, + ownedSubjectTable: ready.next.ownedSubjectTable, + rootClaimSet: ready.next.rootClaimSet, + capacityState: ready.next.capacityState, + receipt: ready.next.receipt, + }).epoch; + return decodeSystemRecordAppliedSnapshotV1({ + networkId: INITIAL.networkId, + stableKeyHash: computeStableKey(INITIAL), + materializationEpoch: EPOCH, + quads: [...prior.record, ...prior.capacity, ...currentEpoch, ...prior.receipt], + }); +} + function snapshotWithAppliedState( ready: SystemRecordActiveReplacementReadyV1, appliedState: SystemRecordAppliedStatePresentV1, diff --git a/packages/storage/test/system-record-state-snapshot-v1.test.ts b/packages/storage/test/system-record-state-snapshot-v1.test.ts index ffc02a3fb..eca8d397e 100644 --- a/packages/storage/test/system-record-state-snapshot-v1.test.ts +++ b/packages/storage/test/system-record-state-snapshot-v1.test.ts @@ -1,26 +1,32 @@ import { describe, expect, it } from 'vitest'; +import { escapeSparqlLiteral } from '@origintrail-official/dkg-core'; import { canonicalizeOwnedSubjectTableObjectV1, + canonicalizeSystemRecordMaterializationReceiptV1, computeOwnedSubjectTableDigestV1, computeSystemRecordAccountedBytesV1, computeSystemRecordAppliedStateDigestV1, + computeSystemRecordMaterializationReceiptDigestV1, computeSystemRecordRootClaimSetDigestV1, computeSystemRecordStableKeyHashV1, SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES, type OwnedSubjectTableObjectV1, type SystemRecordAppliedStatePresentV1, type SystemRecordCapacityStateV1, + type SystemRecordMaterializationReceiptV1, } from '@origintrail-official/dkg-core/system-record-v1'; import { buildSystemRecordReservedStateQuadsV1, systemRecordRootClaimSubjectV1, + SYSTEM_RECORD_V1_JSON_DATATYPE, SYSTEM_RECORD_V1_PREDICATES, } from '../src/system-record-rdf-schema-v1-internal.js'; import { assertAuthenticSystemRecordAppliedSnapshotV1, assertSystemRecordRootClaimSnapshotV1, decodeSystemRecordAppliedSnapshotV1, + requiresSystemRecordSnapshotRematerializationV1, } from '../src/system-record-state-snapshot-v1-internal.js'; const NETWORK = 'otp:20430' as const; @@ -79,8 +85,10 @@ describe('system-record reserved-state snapshot decoder', () => { appliedState: { stableKeyHash: STABLE_KEY, stateRevision: '4' }, capacityState: { revision: '7' }, materializationEpoch: '2', + appliedTupleEpoch: '2', }); if (decoded.state !== 'present') throw new Error('expected present state'); + expect(requiresSystemRecordSnapshotRematerializationV1(decoded)).toBe(false); expect(decoded.expectedRootClaimQuads).toEqual(canonical.rootClaims); expect(decoded.previousReservedQuads).toHaveLength(12); expect(Object.isFrozen(decoded)).toBe(true); @@ -88,6 +96,78 @@ describe('system-record reserved-state snapshot decoder', () => { expect(() => assertAuthenticSystemRecordAppliedSnapshotV1({ ...decoded })).toThrow(/exact decoder/); }); + it('decodes an exact prior-epoch tuple against the current durable epoch', () => { + const prior = tuple({ appliedState: state({ materializationEpoch: '1' }) }); + const currentEpoch = tuple().epoch; + const decoded = decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [...prior.record, ...prior.capacity, ...currentEpoch, ...prior.receipt], + }); + + expect(decoded).toMatchObject({ + state: 'present', + materializationEpoch: '2', + appliedTupleEpoch: '1', + appliedState: { materializationEpoch: '1' }, + receipt: { materializationEpoch: '1' }, + }); + if (decoded.state !== 'present') throw new Error('expected present state'); + expect(requiresSystemRecordSnapshotRematerializationV1(decoded)).toBe(true); + expect(decoded.previousReservedQuads).toEqual(expect.arrayContaining(currentEpoch)); + expect(decoded.previousReservedQuads).not.toEqual(expect.arrayContaining(prior.epoch)); + }); + + it('rejects a persisted tuple from a future materialization epoch', () => { + const future = tuple({ appliedState: state({ materializationEpoch: '3' }) }); + const currentEpoch = tuple().epoch; + + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [...future.record, ...future.capacity, ...currentEpoch, ...future.receipt], + })).toThrow(/epoch binding/); + }); + + it('rejects split applied-state and receipt epochs', () => { + const prior = tuple({ appliedState: state({ materializationEpoch: '1' }) }); + const current = tuple(); + + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [...prior.record, ...prior.capacity, ...current.epoch, ...current.receipt], + })).toThrow(/epoch binding/); + }); + + it('rejects split receipt revision, applied-state digest, and head digest bindings', () => { + const appliedState = state(); + const canonical = tuple({ appliedState }); + const canonicalReceipt = receiptFor(appliedState); + const mismatches = [ + { ...canonicalReceipt, stateRevision: '3' }, + { ...canonicalReceipt, appliedStateDigest: `0x${'cc'.repeat(32)}` }, + { ...canonicalReceipt, headDigest: `0x${'dd'.repeat(32)}` }, + ] as const; + + for (const receipt of mismatches) { + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [ + ...canonical.record, + ...canonical.capacity, + ...canonical.epoch, + ...canonicalReceiptQuads(canonical.receipt, receipt), + ], + })).toThrow(/epoch binding/); + } + }); + it('rejects missing, extra, duplicate, malformed, and mismatched-epoch rows', () => { const canonical = tuple(); const all = [...canonical.record, ...canonical.capacity, ...canonical.epoch, ...canonical.receipt]; @@ -135,6 +215,11 @@ describe('system-record reserved-state snapshot decoder', () => { predicate: canonical.capacity[0].predicate, object: canonical.capacity[0].object, }])).toThrow(/fixed canonical RDF schema/); + expect(() => decode([...all, { + ...canonical.epoch[0], + predicate: SYSTEM_RECORD_V1_PREDICATES.root, + object: ROOT, + }])).toThrow(/fixed canonical RDF schema/); expect(() => decode(all.map((quad) => quad === canonical.record[0] ? { ...quad, object: '"not-json"^^' } : quad))).toThrow(); @@ -335,19 +420,41 @@ function tuple(overrides: { projectionBytes: '8192', projectionQuads: '6', }, - receipt: { - objectType: 'system-record-materialization-receipt', - kind: 'agents', - networkId: NETWORK, - stableKeyHash: STABLE_KEY, - stateRevision: appliedState.stateRevision, - appliedStateDigest, - headDigest: appliedState.headDigest, - materializationEpoch: appliedState.materializationEpoch, - }, + receipt: receiptFor(appliedState, appliedStateDigest), }); } +function receiptFor( + appliedState: SystemRecordAppliedStatePresentV1, + appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState), +): Parameters[0]['receipt'] { + return { + objectType: 'system-record-materialization-receipt', + kind: 'agents', + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + stateRevision: appliedState.stateRevision, + appliedStateDigest, + headDigest: appliedState.headDigest, + materializationEpoch: appliedState.materializationEpoch, + }; +} + +function canonicalReceiptQuads( + template: ReturnType['receipt'], + receipt: SystemRecordMaterializationReceiptV1, +): ReturnType['receipt'] { + const bytes = canonicalizeSystemRecordMaterializationReceiptV1(receipt); + const json = Buffer.from(bytes).toString('utf8'); + const digest = computeSystemRecordMaterializationReceiptDigestV1(receipt); + return Object.freeze(template.map((quad) => Object.freeze({ + ...quad, + object: quad.predicate === SYSTEM_RECORD_V1_PREDICATES.receipt + ? `"${escapeSparqlLiteral(json)}"^^<${SYSTEM_RECORD_V1_JSON_DATATYPE}>` + : `"${digest}"`, + }))); +} + function state( overrides: Partial = {}, ): SystemRecordAppliedStatePresentV1 { diff --git a/packages/storage/test/system-record-verified-replacement-v1.test.ts b/packages/storage/test/system-record-verified-replacement-v1.test.ts index b691c368c..033846664 100644 --- a/packages/storage/test/system-record-verified-replacement-v1.test.ts +++ b/packages/storage/test/system-record-verified-replacement-v1.test.ts @@ -15,6 +15,7 @@ import { computeOwnedSubjectTableDigestV1, digestSystemRecordBytesV1, SYSTEM_RECORD_DIGEST_DOMAINS_V1, + SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES, type AgentProfileActiveHeadObjectV1, type AgentProfileVerifiedAuthoritySummaryV1, type NetworkIdV1, @@ -22,11 +23,14 @@ import { } from '@origintrail-official/dkg-core/system-record-v1'; import { describe, expect, it } from 'vitest'; +import { createManagedOxigraphOwnershipControllerV1 } from '../src/managed-oxigraph-ownership-v1-internal.js'; +import { createSystemRecordNonQueuedReservationGateV1 } from '../src/system-record-reservation-gate-v1-internal.js'; import { createSystemRecordVerifiedReplacementRegistryV1, type SystemRecordActiveReplacementIssueV1, type SystemRecordVerifiedReplacementLaneBindingV1, } from '../src/system-record-verified-replacement-v1-internal.js'; +import { resolveOwnedSystemRecordRuntimeV1 } from '../src/system-record-runtime-v1-internal.js'; interface Vectors { readonly variants: { @@ -286,6 +290,22 @@ describe('system-record verified replacement V1', () => { expect(() => registry.consumer.consume(handle, bindings)).toThrow(/already consumed/); }); + it('keeps one exact nonqueued reservation after wrong-owner or partial release attempts', () => { + const gate = createSystemRecordNonQueuedReservationGateV1(); + const firstOwner = Object.freeze(Object.create(null) as object); + const secondOwner = Object.freeze(Object.create(null) as object); + const bytes = SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES; + + gate.acquire(firstOwner, bytes); + expect(() => gate.acquire(secondOwner, 1)).toThrow(/reservation is already live/); + expect(() => gate.release(secondOwner, bytes)).toThrow(/accountant state is inconsistent/); + expect(() => gate.release(firstOwner, bytes - 1)).toThrow(/accountant state is inconsistent/); + expect(() => gate.acquire(secondOwner, 1)).toThrow(/reservation is already live/); + gate.release(firstOwner, bytes); + expect(() => gate.acquire(secondOwner, bytes)).not.toThrow(); + gate.release(secondOwner, bytes); + }); + it('owns one nonqueued atomic reservation and releases handle or facts exactly once', () => { const registry = createSystemRecordVerifiedReplacementRegistryV1(); const { input, bindings } = fixture(); @@ -301,6 +321,90 @@ describe('system-record verified replacement V1', () => { expect(registry.issuer.issueActive(input)).toBeDefined(); }); + it('resolves one runtime per authentic lease under one process-wide reservation', () => { + const firstOwnership = createManagedOxigraphOwnershipControllerV1( + 'http://127.0.0.1:7878/query', + 'http://127.0.0.1:7878/update', + ); + const secondOwnership = createManagedOxigraphOwnershipControllerV1( + 'http://127.0.0.1:7879/query', + 'http://127.0.0.1:7879/update', + ); + firstOwnership.bindReadyGeneration(); + secondOwnership.bindReadyGeneration(); + + const first = resolveOwnedSystemRecordRuntimeV1(firstOwnership.lease); + const firstAgain = resolveOwnedSystemRecordRuntimeV1(firstOwnership.lease); + const second = resolveOwnedSystemRecordRuntimeV1(secondOwnership.lease); + expect(firstAgain).toBe(first); + expect(second).not.toBe(first); + expect(() => resolveOwnedSystemRecordRuntimeV1( + Object.freeze(Object.create(null) as object) as typeof firstOwnership.lease, + )).toThrow(/authentic managed Oxigraph ownership lease/); + + const { input } = fixture(); + const firstHandle = first.issuer.issueActive(input); + expect(() => second.issuer.issueActive(input)).toThrow(/reservation is already live/); + first.consumer.release(firstHandle); + + const secondHandle = second.issuer.issueActive(input); + second.consumer.release(secondHandle); + }); + + it('keeps ownership liveness outside persisted runtime configuration', () => { + const ownership = createManagedOxigraphOwnershipControllerV1( + 'http://127.0.0.1:7880/query', + 'http://127.0.0.1:7880/update', + ); + const runtime = resolveOwnedSystemRecordRuntimeV1(ownership.lease); + const { input } = fixture(); + + expect(() => runtime.issuer.issueActive(input)).toThrow(/ownership lease is not ready/); + ownership.bindReadyGeneration(); + const handle = runtime.issuer.issueActive(input); + runtime.consumer.release(handle); + ownership.invalidate('shutdown'); + expect(() => runtime.issuer.issueActive(input)).toThrow(/ownership lease is not ready/); + }); + + it('refuses diagnostic leases that do not prove the managed listener endpoints', () => { + const diagnostic = createManagedOxigraphOwnershipControllerV1(); + diagnostic.bindReadyGeneration(); + expect(() => resolveOwnedSystemRecordRuntimeV1( + diagnostic.lease, + )).toThrow(/endpoint-bound managed Oxigraph ownership lease/); + }); + + it('holds the process reservation across recovery ownership until settlement', async () => { + const firstOwnership = createManagedOxigraphOwnershipControllerV1( + 'http://127.0.0.1:7881/query', + 'http://127.0.0.1:7881/update', + ); + const secondOwnership = createManagedOxigraphOwnershipControllerV1( + 'http://127.0.0.1:7882/query', + 'http://127.0.0.1:7882/update', + ); + firstOwnership.bindReadyGeneration(); + secondOwnership.bindReadyGeneration(); + const first = resolveOwnedSystemRecordRuntimeV1(firstOwnership.lease); + const second = resolveOwnedSystemRecordRuntimeV1(secondOwnership.lease); + const { input, bindings } = fixture(); + const facts = first.consumer.consume(first.issuer.issueActive(input), bindings); + const ownership = Object.freeze(Object.create(null) as object); + let settle!: () => void; + const completion = new Promise((resolve) => { + settle = resolve; + }); + + first.consumer.transferToRecovery(facts, ownership, completion); + expect(() => second.issuer.issueActive(input)).toThrow(/reservation is already live/); + settle(); + await completion; + await Promise.resolve(); + const next = second.issuer.issueActive(input); + second.consumer.release(next); + }); + it('discards only a live unconsumed proof and refuses aliases after consumption', () => { const registry = createSystemRecordVerifiedReplacementRegistryV1(); const { input, bindings } = fixture(); @@ -636,5 +740,6 @@ describe('system-record verified replacement V1', () => { it('is not exported from the storage package barrel', async () => { const storage = await import('../src/index.js'); expect('createSystemRecordVerifiedReplacementRegistryV1' in storage).toBe(false); + expect('resolveOwnedSystemRecordRuntimeV1' in storage).toBe(false); }); });