From 19563e556ea2230db8aa3f26926436c4cf6d1549 Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Thu, 6 Aug 2026 22:36:16 +0200 Subject: [PATCH 1/3] feat(storage): add default-unused atomic system-record apply --- .../system-record-managed-ownership.yml | 15 +- devnet/issue-2052-managed-ownership/run.ts | 10 +- docs/adr/0002-system-record-sync-v1.md | 53 +- packages/cli/src/daemon/oxigraph-server.ts | 136 +- packages/cli/test/oxigraph-managed.test.ts | 2 + packages/cli/test/oxigraph-server.test.ts | 61 + packages/core/src/ka-bundle-v1.ts | 15 +- .../src/system-record-applied-state-v1.ts | 9 + .../src/system-record-codec-primitives-v1.ts | 33 + packages/core/src/system-record-limits-v1.ts | 9 + packages/core/src/system-record-objects-v1.ts | 35 + packages/core/src/system-record-v1.ts | 2 + .../system-record-applied-state-v1.test.ts | 61 + .../core/test/system-record-limits-v1.test.ts | 8 + .../test/system-record-objects-v1.test.ts | 1 + .../src/adapters/managed-http-client.ts | 404 +++++- packages/storage/src/adapters/sparql-http.ts | 386 +++++- .../managed-oxigraph-ownership-v1-internal.ts | 134 +- ...system-record-apply-command-v1-internal.ts | 361 ++++++ ...ecord-atomic-apply-executor-v1-internal.ts | 1134 ++++++++++++++++ .../system-record-inspection-v1-internal.ts | 512 ++++++++ ...ecord-materialization-epoch-v1-internal.ts | 230 ++++ .../src/system-record-materializer-v1.ts | 1154 ++++++++++++++++- .../system-record-next-state-v1-internal.ts | 882 +++++++++++++ .../system-record-rdf-schema-v1-internal.ts | 319 +++++ ...ystem-record-state-snapshot-v1-internal.ts | 502 +++++++ .../system-record-utf8-order-v1-internal.ts | 21 + ...record-verified-replacement-v1-internal.ts | 899 +++++++++++++ ...ystem-record-active-replacement-fixture.ts | 203 +++ .../test/managed-http-client-v1.test.ts | 232 +++- .../managed-oxigraph-ownership-v1.test.ts | 97 +- .../system-record-apply-command-v1.test.ts | 105 ++ ...em-record-atomic-apply-executor-v1.test.ts | 888 +++++++++++++ ...tem-record-capability-discovery-v1.test.ts | 64 +- ...ord-control-barrier-integration-v1.test.ts | 91 +- .../test/system-record-inspection-v1.test.ts | 174 +++ ...cord-managed-mutation-admission-v1.test.ts | 265 ++++ ...d-materialization-epoch-adapter-v1.test.ts | 111 ++ ...em-record-materialization-epoch-v1.test.ts | 159 +++ ...m-record-materializer-lifecycle-v1.test.ts | 1116 +++++++++++++++- .../test/system-record-next-state-v1.test.ts | 856 ++++++++++++ .../test/system-record-rdf-schema-v1.test.ts | 163 +++ .../system-record-state-snapshot-v1.test.ts | 398 ++++++ .../test/system-record-utf8-order-v1.test.ts | 15 + ...tem-record-verified-replacement-v1.test.ts | 640 +++++++++ 45 files changed, 12681 insertions(+), 284 deletions(-) create mode 100644 packages/storage/src/system-record-apply-command-v1-internal.ts create mode 100644 packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts create mode 100644 packages/storage/src/system-record-inspection-v1-internal.ts create mode 100644 packages/storage/src/system-record-materialization-epoch-v1-internal.ts create mode 100644 packages/storage/src/system-record-next-state-v1-internal.ts create mode 100644 packages/storage/src/system-record-rdf-schema-v1-internal.ts create mode 100644 packages/storage/src/system-record-state-snapshot-v1-internal.ts create mode 100644 packages/storage/src/system-record-utf8-order-v1-internal.ts create mode 100644 packages/storage/src/system-record-verified-replacement-v1-internal.ts create mode 100644 packages/storage/test/helpers/system-record-active-replacement-fixture.ts create mode 100644 packages/storage/test/system-record-apply-command-v1.test.ts create mode 100644 packages/storage/test/system-record-atomic-apply-executor-v1.test.ts create mode 100644 packages/storage/test/system-record-inspection-v1.test.ts create mode 100644 packages/storage/test/system-record-managed-mutation-admission-v1.test.ts create mode 100644 packages/storage/test/system-record-materialization-epoch-adapter-v1.test.ts create mode 100644 packages/storage/test/system-record-materialization-epoch-v1.test.ts create mode 100644 packages/storage/test/system-record-next-state-v1.test.ts create mode 100644 packages/storage/test/system-record-rdf-schema-v1.test.ts create mode 100644 packages/storage/test/system-record-state-snapshot-v1.test.ts create mode 100644 packages/storage/test/system-record-utf8-order-v1.test.ts create mode 100644 packages/storage/test/system-record-verified-replacement-v1.test.ts diff --git a/.github/workflows/system-record-managed-ownership.yml b/.github/workflows/system-record-managed-ownership.yml index ba71326732..f58606e787 100644 --- a/.github/workflows/system-record-managed-ownership.yml +++ b/.github/workflows/system-record-managed-ownership.yml @@ -19,7 +19,10 @@ name: System-record managed ownership evidence # # What it does NOT prove: predecessor entries run against the CURRENT binary, # not against each pinned commit — no predecessor is checked out or built. The -# full-state CAS has not landed, so no verified apply is dispatched here. +# B3 active-replacement CAS exists, but this ownership artifact still does +# not dispatch a verified apply. Atomic apply, lost-response recovery, and +# maximum-size p99/RSS remain separate activation gates; this workflow must not +# be cited as evidence for them. on: push: @@ -119,9 +122,19 @@ jobs: test/internal-graph-policy.test.ts \ test/managed-oxigraph-ownership-v1.test.ts \ test/reserved-internal-graph-mutation-guard.test.ts \ + test/system-record-apply-command-v1.test.ts \ + test/system-record-atomic-apply-executor-v1.test.ts \ test/system-record-materializer-lifecycle-v1.test.ts \ test/system-record-control-barrier-integration-v1.test.ts \ test/system-record-capability-discovery-v1.test.ts \ + test/system-record-inspection-v1.test.ts \ + test/system-record-managed-mutation-admission-v1.test.ts \ + test/system-record-materialization-epoch-v1.test.ts \ + test/system-record-next-state-v1.test.ts \ + test/system-record-rdf-schema-v1.test.ts \ + test/system-record-state-snapshot-v1.test.ts \ + test/system-record-utf8-order-v1.test.ts \ + test/system-record-verified-replacement-v1.test.ts \ test/store-scheduler-system-record-admission.test.ts - name: Run live managed-ownership gate diff --git a/devnet/issue-2052-managed-ownership/run.ts b/devnet/issue-2052-managed-ownership/run.ts index 7f431debb9..d6d09a3c71 100644 --- a/devnet/issue-2052-managed-ownership/run.ts +++ b/devnet/issue-2052-managed-ownership/run.ts @@ -582,7 +582,10 @@ async function main(): Promise { (await countQuadsInGraph(server.queryEndpoint, SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH)); // ---- Capability fail-closed matrix against the LIVE endpoint. - const ownership = createManagedOxigraphOwnershipControllerV1(); + const ownership = createManagedOxigraphOwnershipControllerV1( + server.queryEndpoint, + server.updateEndpoint, + ); ownership.bindReadyGeneration(); const handoff: ManagedOxigraphSupervisorHandoffV1 = { stopAndProveOwnedChildDead: async () => undefined, @@ -638,7 +641,10 @@ async function main(): Promise { capability.throughEnabledChangelog = withChangelog.getSystemRecordLaneControllerV1?.() !== undefined; - const terminalOwnership = createManagedOxigraphOwnershipControllerV1(); + const terminalOwnership = createManagedOxigraphOwnershipControllerV1( + server.queryEndpoint, + server.updateEndpoint, + ); terminalOwnership.bindReadyGeneration(); terminalOwnership.invalidate('port-release-unproven'); const terminal = await build( diff --git a/docs/adr/0002-system-record-sync-v1.md b/docs/adr/0002-system-record-sync-v1.md index 08954eca54..2165d79416 100644 --- a/docs/adr/0002-system-record-sync-v1.md +++ b/docs/adr/0002-system-record-sync-v1.md @@ -711,6 +711,10 @@ roots (15 current-plus-historical roots total), 16 fixed preallocated conflict-d materialization epoch, and accounted bytes. Its digest domain is `dkg-system-record-applied-state-v1\n` and excludes only the digest field. Global capacity state has its own revision, live-record count, and accounted bytes. +The current authority sequence is derived from the retained contiguous lineage; +the current head version is stored as a separate storage-private predicate in the +same reserved RDF tuple. This keeps the frozen B1 applied-state codec and digest +stable while allowing the atomic writer to reject stale same-authority heads. For every present record, `accountedBytes` is canonical and exact: `64 KiB fixed state/security precharge + ownedSubjectTableBytes + projectionBytes + pendingDeletionTableBytes`. The pending term is zero when omitted; current JSON size is @@ -726,6 +730,11 @@ The exact canonical sorted duplicate-free prior subject list lives in a separate indexed per-record reserved table in the same transaction boundary. Its encoded bytes are capped at 256 KiB and committed by the state table digest/count; header and table bytes both count toward per-record and aggregate accounting. +For every nonzero verified candidate authority sequence, the opaque authority summary also +binds the latest transition's prior-head digest. Storage consults that value only for a +local `+1` authority transition and requires it to equal the currently applied head digest; +ordinary same-sequence version advancement never derives predecessor authority from local +state or from a caller-authored field. Stack B2 exposes a passive controller. Merely discovering it performs no work; an explicit non-serializable activation lease opens a generation-bound session. Callers @@ -847,6 +856,16 @@ callers cannot author a delete scope. Storage consumes it once and, for tombston recomputes the table digest/count against the verified predecessor head before deriving the exact deletion. Missing/mismatched/reused/cross-session payloads fail before dispatch. +The storage transition is guarded by both the reserved-state CAS and a +scheduler-fenced projection preflight. While holding the exclusive `agents` permit, +storage reads the exact prior/next subject union from the selected projection graph, +incrementally hashes its strict canonical graphless N-Triples lines under +`dkg-ka-projection-v1\n`, and compares digest, byte count, and quad count with the +applied snapshot. Canonical line-order failure or any mismatch defers with zero +update dispatch, including an absent snapshot with a pre-existing candidate-subject +row and equal-head projection drift. Inspected prior rows are never enumerated into +the SPARQL update. + The expected-state CAS covers `(stateRevision, appliedStateDigest, headDigest, transitionLineage, conflictEvidenceDigest?, ownedSubjectTableDigest, @@ -962,8 +981,9 @@ ones before state changes. A different enabled-set descriptor requires disabling reopening this same aggregate session; `ontology` never creates a second controller, barrier, epoch, or accountant. -The process-global scheduler accepts exactly one daemon-managed owned-store controller -registration. A second registration fails before capability exposure, open, or any +The process-global scheduler is the explicit single managed-writer activation gate: +it accepts exactly one daemon-managed owned-store controller registration. A second +registration fails before capability exposure, open, or any mutation and can never enter recovery. Other unowned/legacy store identities stay outside the capability and never wait on ordinary enabled-lane barriers, although each enable handoff conservatively drains pre-existing untagged work. @@ -1006,7 +1026,7 @@ SPARQL mutation consumes and reports one ordinary store slot as `control_epoch_active_slot_ms` plus latency. An HTTP 204 does not reveal whether a conditional update matched. The transaction -writes one bounded nonce/receipt only when full state, every root claim/reverse binding, +writes one deterministic state-bound receipt only when full state, every root claim/reverse binding, epoch, and capacity match; a bounded post-read maps it to a typed result. Timeout, lost/malformed response, or child-generation change in flight is indeterminate: validated state is invalidated, wrappers dirty, and generation admission seals. @@ -1164,8 +1184,11 @@ claim about serialized payload size. The object cache is disk-only. One atomic record additionally has distinct preflight ceilings: 1 MiB encoded bundle, 64 KiB signed head envelope, 10,000 quads/2,048 union subjects, 2 MiB -canonical decoded terms, 4 MiB encoded SPARQL request body, and 12 MiB weighted -end-to-end transient heap. Only one bundle decode/apply lease and one materializer write +canonical decoded terms, 1 MiB encoded exact-reserved inspection response, 4 MiB +encoded projection-inspection response and SPARQL request body, 8 MiB storage-local +retained preparation/inspection/receipt buffers, and 12 MiB weighted end-to-end +transient heap. The 8-MiB bound is a subset of, not an addition to, the 12-MiB +lease. Only one bundle decode/apply lease and one materializer write may be physically in flight. Exact-object transport has two separate process-wide, nonqueued permits: one outbound requester response stream and one inbound provider response stream. Requester permit absence is typed slice-deferred; provider permit or @@ -1455,6 +1478,26 @@ the materialization epoch, restores legacy authority for the cohort, and require fresh complete activation gate before re-entry. Mainnet needs its own reviewed activation release. +The B3 storage transaction may merge only as a default-unused boundary: production +composition retains the registry consumer and deliberately discards its matching issuer, +and no lifecycle path opens the lane. Before any producer or receiver receives that +issuer or opens the lane, the remaining storage activation gates are mandatory: + +- verified same-version forks and root-claim collisions atomically quarantine the + incumbent with exact post-read and uncertain-write recovery; +- a tuple from a prior materialization epoch is recovered or replaced under the current + epoch instead of becoming a permanent validation mismatch; +- the 64-MiB runtime accountant is one injected process owner shared by decode, apply, + recovery, transport, control, and signature-verification reservations; +- the maximum 10,000-row/2,048-subject transaction fits the 12-MiB weighted lease and + 8-MiB prepared subcap in a live test, or the frozen row/byte limits are lowered; and +- producer/receiver caller abort, lost-response recovery, and maximum-size p99/RSS + evidence pass the live activation gate. + +Until those conditions are implemented and measured, the private consumer must reject +every caller-authored proof before inspection or mutation, and the ownership workflow +must continue to report only lifecycle evidence rather than atomic-apply conformance. + ## Rollout and Rollback 1. Merge canonical objects, validators, and storage capability default-unused. diff --git a/packages/cli/src/daemon/oxigraph-server.ts b/packages/cli/src/daemon/oxigraph-server.ts index 1229938f3b..fd4f275a84 100644 --- a/packages/cli/src/daemon/oxigraph-server.ts +++ b/packages/cli/src/daemon/oxigraph-server.ts @@ -69,6 +69,7 @@ * crash-restart, ownership loss, and shutdown without launching a real binary. */ import { spawn, type ChildProcess } from 'node:child_process'; +import { performance } from 'node:perf_hooks'; import { createManagedOxigraphOwnershipControllerV1, type ManagedOxigraphOwnershipLeaseV1, @@ -252,6 +253,8 @@ const DEFAULT_RESTART_MAX_MS = 30_000; * line rather than the store. */ const PORT_RELEASE_PROBE_ATTEMPTS = 5; +/** Upper bound of the ss/lsof/fuser ownership lookup used by this module. */ +const LISTENER_OWNERSHIP_PROBE_BUDGET_MS = 6_500; /** Sentinel for "no generation has ever been bound on this lease". */ const UNBOUND_GENERATION = '0'; @@ -259,6 +262,23 @@ function sleep(ms: number): Promise { return new Promise((res) => setTimeout(res, ms)); } +function remainingMonotonicMs(absoluteDeadlineMs: number | undefined): number | undefined { + return absoluteDeadlineMs === undefined + ? undefined + : Math.max(0, absoluteDeadlineMs - performance.now()); +} + +function boundedPhaseDelayMs( + wantedMs: number, + absoluteDeadlineMs: number | undefined, +): number { + const remaining = remainingMonotonicMs(absoluteDeadlineMs); + if (remaining !== undefined && remaining <= 0) { + throw new Error('Managed Oxigraph clean-generation recovery deadline expired'); + } + return Math.max(1, Math.ceil(Math.min(wantedMs, remaining ?? wantedMs))); +} + function normalizePositiveInteger(value: number | undefined): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value @@ -331,7 +351,14 @@ export async function startOxigraphServer( * out: consumers get {@link OxigraphServerOwnershipV1}, which can read the * lease but not mint one. */ - const ownership = createManagedOxigraphOwnershipControllerV1(); + // Only the exact production listener spelling may mint the endpoint-bound + // B3 capability. `host` remains overridable for tests and compatible local + // callers; those servers retain the B2 diagnostic lifecycle lease, but that + // endpoint-less lease can never satisfy the atomic materializer's endpoint + // identity check. + const ownership = host === DEFAULT_HOST + ? createManagedOxigraphOwnershipControllerV1(queryEndpoint, updateEndpoint) + : createManagedOxigraphOwnershipControllerV1(); let state: OxigraphLifecycleState = 'starting'; /** @@ -366,6 +393,8 @@ export async function startOxigraphServer( // childAlive() wrongly report it alive. Track them so childAlive() and the // ready/revive loops treat a spawn error as a dead child. const erroredChildren = new WeakSet(); + /** Children intentionally signalled by a clean-generation handoff. */ + const handoffRetiringChildren = new WeakSet(); const oomSnapshots = new WeakMap(); // --------------------------------------------------------------------- @@ -524,7 +553,7 @@ export async function startOxigraphServer( // The child we own is gone. Record it before the phase check below so // the lease stays honest even for a startup-phase or revive-window // death, where nothing was ever bound to lose. - ownership.invalidate('child-exit'); + if (!handoffRetiringChildren.delete(c)) ownership.invalidate('child-exit'); // Two cases land here outside the `ready` phase and must NOT (re)start: // 1. Startup-phase exit — usually a bind failure (the port is taken // by another local SPARQL server). The ready loop observes the @@ -609,8 +638,11 @@ export async function startOxigraphServer( return walk(error); }; - const probeBind = async (): Promise => { + const probeBind = async ( + absoluteDeadlineMs?: number, + ): Promise => { try { + const timeoutMs = boundedPhaseDelayMs(readyIntervalMs + 1_000, absoluteDeadlineMs); const res = await io.fetch(queryEndpoint, { method: 'POST', headers: { @@ -618,7 +650,7 @@ export async function startOxigraphServer( Accept: 'application/sparql-results+json', }, body: 'ASK { ?s ?p ?o }', - signal: AbortSignal.timeout(readyIntervalMs + 1_000), + signal: AbortSignal.timeout(timeoutMs), }); // Even a non-2xx answer proves something is listening — which is exactly // what the release proof must not mistake for a free port. @@ -637,8 +669,9 @@ export async function startOxigraphServer( * The cheap first half of the readiness probe; anything short of a usable * answer is a no. */ - const endpointAnswers = async (): Promise => { + const endpointAnswers = async (absoluteDeadlineMs?: number): Promise => { try { + const timeoutMs = boundedPhaseDelayMs(readyIntervalMs + 1_000, absoluteDeadlineMs); const res = await io.fetch(queryEndpoint, { method: 'POST', headers: { @@ -646,7 +679,7 @@ export async function startOxigraphServer( Accept: 'application/sparql-results+json', }, body: 'ASK { ?s ?p ?o }', - signal: AbortSignal.timeout(readyIntervalMs + 1_000), + signal: AbortSignal.timeout(timeoutMs), }); return res.ok; } catch { @@ -667,11 +700,17 @@ export async function startOxigraphServer( } }; - const probeReady = async (): Promise => { + const probeReady = async (absoluteDeadlineMs?: number): Promise => { const c = child; if (!c || !childAlive()) return null; - if (!(await endpointAnswers())) return null; + if (!(await endpointAnswers(absoluteDeadlineMs))) return null; + boundedPhaseDelayMs(1, absoluteDeadlineMs); + const remaining = remainingMonotonicMs(absoluteDeadlineMs); + if (remaining !== undefined && remaining < LISTENER_OWNERSHIP_PROBE_BUDGET_MS) { + return null; + } const listenerPid = await resolveListenOwner(c); + boundedPhaseDelayMs(1, absoluteDeadlineMs); return listenerPid !== null && childAlive() ? listenerPid : null; }; @@ -907,17 +946,26 @@ export async function startOxigraphServer( * We only ever PROBE here. No pid observed on the port is ever signalled — * every kill in this module goes through the tracked `ChildProcess`. */ - const proveManagedPortRelease = async (exited: ChildProcess | null): Promise => { + const proveManagedPortRelease = async ( + exited: ChildProcess | null, + absoluteDeadlineMs?: number, + ): Promise => { const interval = Math.max(1, Math.floor(stopGraceMs / PORT_RELEASE_PROBE_ATTEMPTS)); let last: BindProbeResultV1 = 'inconclusive'; for (let attempt = 1; attempt <= PORT_RELEASE_PROBE_ATTEMPTS; attempt += 1) { - last = await probeBind(); + if ((remainingMonotonicMs(absoluteDeadlineMs) ?? 1) <= 0) break; + last = await probeBind(absoluteDeadlineMs); // The socket is gone, positively: the OS refused the connection. if (last === 'refused') return true; if (attempt === PORT_RELEASE_PROBE_ATTEMPTS) break; - await sleep(interval); + await sleep(boundedPhaseDelayMs(interval, absoluteDeadlineMs)); } - const owner = exited === null ? null : await resolveListenOwner(exited); + // Listener ownership here is diagnostic only; release proof came from the + // refused-connection probes above. Do not spend a non-cancellable command + // fallback after a recovery deadline has been supplied. + const owner = exited === null || absoluteDeadlineMs !== undefined + ? null + : await resolveListenOwner(exited); log( `[oxigraph] ${bind} release could not be proven after the managed child exited ` + `(last probe: ${last}).`, @@ -934,13 +982,22 @@ export async function startOxigraphServer( }; /** SIGTERM, escalating to SIGKILL after `stopGraceMs`, resolving on exit. */ - const awaitChildExit = async (c: ChildProcess): Promise => { - await new Promise((resolve) => { + const awaitChildExit = async ( + c: ChildProcess, + absoluteDeadlineMs?: number, + ): Promise => { + const killDelayMs = boundedPhaseDelayMs(stopGraceMs, absoluteDeadlineMs); + const deadlineDelayMs = absoluteDeadlineMs === undefined + ? undefined + : boundedPhaseDelayMs(Number.MAX_SAFE_INTEGER, absoluteDeadlineMs); + await new Promise((resolve, reject) => { let settled = false; + let deadlineTimer: ReturnType | undefined; const done = () => { if (settled) return; settled = true; clearTimeout(killTimer); + if (deadlineTimer) clearTimeout(deadlineTimer); resolve(); }; c.once('exit', done); @@ -950,8 +1007,19 @@ export async function startOxigraphServer( log('[oxigraph] did not exit on SIGTERM; sending SIGKILL'); c.kill('SIGKILL'); } - }, stopGraceMs); + }, killDelayMs); killTimer.unref?.(); + if (deadlineDelayMs !== undefined) { + deadlineTimer = setTimeout(() => { + if (settled) return; + settled = true; + clearTimeout(killTimer); + c.removeListener('exit', done); + try { c.kill('SIGKILL'); } catch { /* best effort */ } + reject(new Error('Managed Oxigraph child did not exit before the recovery deadline')); + }, deadlineDelayMs); + deadlineTimer.unref?.(); + } }); }; @@ -1032,7 +1100,8 @@ export async function startOxigraphServer( * exists to prevent. Resolving quietly would let the lane proceed against a * foreign server. */ - const retireOwnedChildLocked = async (): Promise => { + const retireOwnedChildLocked = async (absoluteDeadlineMs?: number): Promise => { + boundedPhaseDelayMs(1, absoluteDeadlineMs); if (terminating || state === 'closed') { throw new Error( 'Managed Oxigraph supervisor is shutting down; the owned child cannot be retired', @@ -1053,13 +1122,16 @@ export async function startOxigraphServer( ownership.invalidate('stop'); markStoreDown(); const c = child; - // Detach FIRST so the exit we are about to cause cannot be read as a crash - // and schedule a restart behind us. - child = null; + // Keep the process TRACKED until exit is proven. `state === 'recovering'` + // already prevents its exit handler from scheduling a revive, while + // clearing `child` before a recovery deadline would orphan a late SIGKILL + // exit from every subsequent stop/retry path. if (c && c.exitCode === null && c.signalCode === null) { - await awaitChildExit(c); + handoffRetiringChildren.add(c); + await awaitChildExit(c, absoluteDeadlineMs); } - if (!(await proveManagedPortRelease(c))) { + if (child === c) child = null; + if (!(await proveManagedPortRelease(c, absoluteDeadlineMs))) { // We have no child and cannot account for whatever is still on the bind. // Leaving the supervisor merely "open and childless" would keep it in a // state where a later path could still spawn against that listener, so @@ -1098,7 +1170,8 @@ export async function startOxigraphServer( * would be starting a child over a socket whose previous owner is unaccounted * for, which is the one thing the whole handoff exists to rule out. */ - const startCleanGenerationLocked = async (): Promise => { + const startCleanGenerationLocked = async (absoluteDeadlineMs?: number): Promise => { + boundedPhaseDelayMs(1, absoluteDeadlineMs); if (terminating || state === 'closed') { throw new Error( 'Managed Oxigraph supervisor is shutting down; no clean generation can be bound', @@ -1130,11 +1203,14 @@ export async function startOxigraphServer( // well (terminal lease), which is the same class. try { child = spawnChild(); - const deadline = Date.now() + readyTimeoutMs; - while (Date.now() < deadline) { + const deadline = Math.min( + performance.now() + readyTimeoutMs, + absoluteDeadlineMs ?? Number.POSITIVE_INFINITY, + ); + while (performance.now() < deadline) { if (terminating) break; if (!childAlive()) break; - const listenerPid = await probeReady(); + const listenerPid = await probeReady(absoluteDeadlineMs); if (listenerPid !== null && childAlive()) { // Bind FIRST: clearing the phase before a call that can throw would // leave the supervisor claiming a completed handoff it never made. @@ -1143,11 +1219,11 @@ export async function startOxigraphServer( log(`[oxigraph] clean child generation ${generation} bound on ${bind}.`); return; } - await sleep(readyIntervalMs); + await sleep(boundedPhaseDelayMs(readyIntervalMs, deadline)); } throw new Error( `Managed Oxigraph could not prove a clean child generation on ${bind} ` + - `within ${readyTimeoutMs}ms`, + 'before the clean-generation recovery deadline', ); } catch (err) { // The lane's replacement could not be proven. Reap the unproven child and @@ -1172,8 +1248,10 @@ export async function startOxigraphServer( * from config. */ const supervisorHandoff: ManagedOxigraphSupervisorHandoffV1 = Object.freeze({ - stopAndProveOwnedChildDead: () => runExclusive(retireOwnedChildLocked), - startAndProveCleanGeneration: () => runExclusive(startCleanGenerationLocked), + stopAndProveOwnedChildDead: (absoluteDeadlineMs?: number) => + runExclusive(() => retireOwnedChildLocked(absoluteDeadlineMs)), + startAndProveCleanGeneration: (absoluteDeadlineMs?: number) => + runExclusive(() => startCleanGenerationLocked(absoluteDeadlineMs)), }); // --------------------------------------------------------------------- diff --git a/packages/cli/test/oxigraph-managed.test.ts b/packages/cli/test/oxigraph-managed.test.ts index 39a0c4abae..99c153b863 100644 --- a/packages/cli/test/oxigraph-managed.test.ts +++ b/packages/cli/test/oxigraph-managed.test.ts @@ -569,6 +569,8 @@ describe('startManagedOxigraph (real download + real server)', () => { // Exactly one proven generation was bound by the supervised start. expect(result!.ownership.snapshot()).toEqual({ childGeneration: '1', + queryEndpoint: result!.handle.queryEndpoint, + updateEndpoint: result!.handle.updateEndpoint, ready: true, terminal: false, }); diff --git a/packages/cli/test/oxigraph-server.test.ts b/packages/cli/test/oxigraph-server.test.ts index 94052326dc..31edd5dd22 100644 --- a/packages/cli/test/oxigraph-server.test.ts +++ b/packages/cli/test/oxigraph-server.test.ts @@ -267,6 +267,30 @@ describe('startOxigraphServer (real child processes)', () => { } }); + it('preserves non-canonical test hosts without exposing the endpoint-bound B3 capability', async () => { + const port = await freePort(); + const handle = await startOxigraphServer(startOpts(port, { host: 'localhost' })); + const store = new SparqlHttpStore(attachManagedOxigraphLeaseV1( + { + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, + }, + handle.ownership.lease, + handle.supervisorHandoff, + ) as SparqlHttpStoreOptions); + try { + expect(handle.ownership.snapshot()).toEqual({ + childGeneration: '1', + ready: true, + terminal: false, + }); + expect(store.getSystemRecordLaneControllerV1()).toBeUndefined(); + } finally { + await store.close(); + await handle.stop(); + } + }); + it('passes the native Oxigraph query timeout to the child process', async () => { const port = await freePort(); const handle = await startOxigraphServer(startOpts(port, { queryTimeoutS: 35 })); @@ -445,6 +469,8 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { try { expect(handle.ownership.snapshot()).toEqual({ childGeneration: '1', + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, ready: true, terminal: false, }); @@ -507,6 +533,8 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { // Capability is revoked BEFORE any replacement generation exists — // there is no window in which a dead child still looks live. childGeneration: '1', + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, ready: false, terminal: false, lastInvalidation: 'child-exit', @@ -523,6 +551,8 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { await handle.stop(); expect(handle.ownership.snapshot()).toEqual({ childGeneration: '1', + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, ready: false, terminal: true, lastInvalidation: 'shutdown', @@ -553,6 +583,8 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { // SIGKILL, and never closed the lease. expect(handle.ownership.snapshot()).toEqual({ childGeneration: '1', + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, ready: false, terminal: true, lastInvalidation: 'shutdown', @@ -579,6 +611,8 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { expect(await portAnswers(port)).toBe(false); expect(handle.ownership.snapshot()).toEqual({ childGeneration: '1', + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, ready: false, terminal: true, lastInvalidation: 'shutdown', @@ -758,6 +792,8 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { // Liveness is gone but the supervisor is NOT closed: a replacement is // expected, so this must not be terminal the way stop() is. childGeneration: '1', + queryEndpoint: handle.queryEndpoint, + updateEndpoint: handle.updateEndpoint, ready: false, terminal: false, lastInvalidation: 'stop', @@ -772,6 +808,31 @@ describe('startOxigraphServer ownership lease and lifecycle (#2052 B2)', () => { } }); + it('refuses expired recovery deadlines before stop or replacement spawn side effects', async () => { + const port = await freePort(); + const state = { spawns: [] as ChildProcess[], provable: true }; + const handle = await startOxigraphServer(startOpts(port, { io: supervisorIo(state) })); + try { + const expired = performance.now() - 1; + await expect(handle.supervisorHandoff.stopAndProveOwnedChildDead(expired)) + .rejects.toThrow(/deadline expired/i); + expect(state.spawns).toHaveLength(1); + expect(handle.ownership.snapshot()).toMatchObject({ ready: true, childGeneration: '1' }); + + await handle.supervisorHandoff.stopAndProveOwnedChildDead(); + await expect(handle.supervisorHandoff.startAndProveCleanGeneration(expired)) + .rejects.toThrow(/deadline expired/i); + expect(state.spawns).toHaveLength(1); + expect(await portAnswers(port)).toBe(false); + + await handle.supervisorHandoff.startAndProveCleanGeneration(); + expect(state.spawns).toHaveLength(2); + expect(handle.ownership.snapshot()).toMatchObject({ ready: true, childGeneration: '2' }); + } finally { + await handle.stop(); + } + }); + it('REJECTS the retire, and goes terminal, when port release cannot be proven', async () => { const port = await freePort(); const state = { spawns: [] as ChildProcess[], provable: true }; diff --git a/packages/core/src/ka-bundle-v1.ts b/packages/core/src/ka-bundle-v1.ts index 420ed75ae0..16b916204e 100644 --- a/packages/core/src/ka-bundle-v1.ts +++ b/packages/core/src/ka-bundle-v1.ts @@ -58,6 +58,14 @@ export interface DecodedOpaqueKaBundleV1 { readonly blobDigest: Digest32V1; } +/** Compute the frozen projection digest without allocating a synthetic bundle. */ +export function computeKaBundleProjectionDigestV1( + projectionBytes: Uint8Array, +): Digest32V1 { + assertUint8Array(projectionBytes, 'projectionBytes'); + return digestToLowerHex(PROJECTION_DIGEST_DOMAIN_BYTES, projectionBytes); +} + /** * Validate the exact v1 component-length arithmetic without allocating a bundle. * Inputs are bigint so no candidate u64 ever passes through binary floating point. @@ -141,10 +149,7 @@ export function encodeOpaqueKaBundleV1( return { bundleBytes, - projectionDigest: digestToLowerHex( - PROJECTION_DIGEST_DOMAIN_BYTES, - finalizedProjectionBytes, - ), + projectionDigest: computeKaBundleProjectionDigestV1(finalizedProjectionBytes), blobDigest: digestToLowerHex(BLOB_DIGEST_DOMAIN_BYTES, bundleBytes), }; } @@ -193,7 +198,7 @@ export function decodeOpaqueKaBundleV1(bundleBytes: Uint8Array): DecodedOpaqueKa return { projectionBytes, sealBytes, - projectionDigest: digestToLowerHex(PROJECTION_DIGEST_DOMAIN_BYTES, projectionBytes), + projectionDigest: computeKaBundleProjectionDigestV1(projectionBytes), blobDigest: digestToLowerHex(BLOB_DIGEST_DOMAIN_BYTES, bundleBytes), }; } diff --git a/packages/core/src/system-record-applied-state-v1.ts b/packages/core/src/system-record-applied-state-v1.ts index 460f58ce43..5bf8511cf0 100644 --- a/packages/core/src/system-record-applied-state-v1.ts +++ b/packages/core/src/system-record-applied-state-v1.ts @@ -8,6 +8,7 @@ import { EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, type AgentProfileAppliedTransitionV1, } from './system-record-objects-v1.js'; +import { KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1 } from './ka-bundle-v1.js'; import { assertCanonicalSystemRecordPeerIdV1, digestSystemRecordBytesV1, @@ -123,6 +124,12 @@ const ABSENT: SystemRecordAppliedStateAbsentV1 = Object.freeze({ state: 'absent', }); +/** Projection digest committed by every terminal state with zero projection bytes. */ +export const SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1 = digestSystemRecordBytesV1( + KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1, + new Uint8Array(), +); + export function systemRecordAppliedStateAbsentV1(): SystemRecordAppliedStateAbsentV1 { return ABSENT; } @@ -303,11 +310,13 @@ function validateAppliedState(value: unknown): SystemRecordAppliedStateV1 { throw new Error('accountedBytes must equal the fixed state precharge plus exact persistent bytes'); } if (state.status === 'tombstone' && (projectionBytes !== 0n || projectionQuads !== 0n + || state.projectionDigest !== SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1 || ownedCount !== 0n || state.ownedSubjectTableBytes !== '0' || state.ownedSubjectTableDigest !== EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1)) { throw new Error('tombstone applied state must commit the canonical empty projection/table'); } if (state.status === 'active' && (projectionBytes === 0n || projectionQuads === 0n || ownedCount === 0n + || state.projectionDigest === SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1 || state.ownedSubjectTableDigest === EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1)) { throw new Error('active applied state must commit a nonempty projection/table'); } diff --git a/packages/core/src/system-record-codec-primitives-v1.ts b/packages/core/src/system-record-codec-primitives-v1.ts index 7881045d5f..69e8c664da 100644 --- a/packages/core/src/system-record-codec-primitives-v1.ts +++ b/packages/core/src/system-record-codec-primitives-v1.ts @@ -6,6 +6,10 @@ import type { Digest32V1 } from './sync-wire-scalars.js'; const UTF8 = new TextEncoder(); const BASE64URL = /^[A-Za-z0-9_-]+$/; +const TYPED_ARRAY_BYTE_LENGTH = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype) as object, + 'byteLength', +)?.get; export type SystemRecordPeerPublicKeyV1 = string & { readonly __peerPublicKeyV1: true }; @@ -74,3 +78,32 @@ export function digestSystemRecordBytesV1(domain: string, bytes: Uint8Array): Di input.set(bytes, domainBytes.byteLength); return (`0x${Buffer.from(sha256(input)).toString('hex')}`) as Digest32V1; } + +/** Copy bounded bytes through typed-array intrinsics, ignoring subclass methods and species. */ +export function copyBoundedSystemRecordBytesV1( + value: unknown, + maxBytes: number, + label: string, +): Uint8Array { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + failSystemRecordObjectV1('system-record-scalar', `${label} must be bounded Uint8Array bytes`); + } + const byteLength = systemRecordByteLengthV1(value, label); + if (byteLength > maxBytes) { + failSystemRecordObjectV1('system-record-limit', `${label} exceeds ${maxBytes} bytes`); + } + const copy = new Uint8Array(byteLength); + Uint8Array.prototype.set.call(copy, value as Uint8Array); + return copy; +} + +function systemRecordByteLengthV1(value: unknown, label: string): number { + if (!(value instanceof Uint8Array) || TYPED_ARRAY_BYTE_LENGTH === undefined) { + failSystemRecordObjectV1('system-record-scalar', `${label} must be Uint8Array bytes`); + } + try { + return Reflect.apply(TYPED_ARRAY_BYTE_LENGTH, value, []) as number; + } catch (cause) { + failSystemRecordObjectV1('system-record-scalar', `${label} is not a valid Uint8Array`, cause); + } +} diff --git a/packages/core/src/system-record-limits-v1.ts b/packages/core/src/system-record-limits-v1.ts index c34d4b5099..4db208437c 100644 --- a/packages/core/src/system-record-limits-v1.ts +++ b/packages/core/src/system-record-limits-v1.ts @@ -62,7 +62,13 @@ export const SYSTEM_RECORD_STATUS_TIMEOUT_MS = 2_000; export const SYSTEM_RECORD_MAX_ATOMIC_BUNDLE_BYTES = 1024 * 1024; export const SYSTEM_RECORD_MAX_ATOMIC_SIGNED_HEAD_BYTES = 64 * 1024; export const SYSTEM_RECORD_MAX_ATOMIC_DECODED_TERM_BYTES = 2 * 1024 * 1024; +/** Exact reserved tuple response, including a maximum 256-KiB subject table and JSON overhead. */ +export const SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES = 1024 * 1024; +/** Largest encoded SPARQL JSON inspection body retained before strict decoding. */ +export const SYSTEM_RECORD_MAX_ATOMIC_INSPECTION_RESPONSE_BYTES = 4 * 1024 * 1024; export const SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES = 4 * 1024 * 1024; +/** Storage-local retained command/query/update/receipt buffers for one apply. */ +export const SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES = 8 * 1024 * 1024; export const SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES = 12 * 1024 * 1024; export const SYSTEM_RECORD_MAX_BUNDLE_DECODE_CONCURRENCY = 1; export const SYSTEM_RECORD_MAX_MATERIALIZER_WRITE_CONCURRENCY = 1; @@ -139,6 +145,9 @@ export const SYSTEM_RECORD_SERVE_PIN_TIMEOUT_MS = 30_000; export const SYSTEM_RECORD_REPAIR_MIN_DISPATCH_BUDGET_MS = 1_500; export const SYSTEM_RECORD_MATERIALIZER_HEALTHY_P99_MS = 750; export const SYSTEM_RECORD_MATERIALIZER_HARD_TIMEOUT_MS = 1_000; +export const SYSTEM_RECORD_APPLY_TIMEOUT_MS = SYSTEM_RECORD_MATERIALIZER_HARD_TIMEOUT_MS; +export const SYSTEM_RECORD_INSPECTION_TIMEOUT_MS = 1_000; +export const SYSTEM_RECORD_REQUIRED_DISPATCH_BUDGET_MS = 1_500; export const SYSTEM_RECORD_INVENTORY_ROW_VERSION = 1; export const SYSTEM_RECORD_MAX_ROW_BYTES = 512; diff --git a/packages/core/src/system-record-objects-v1.ts b/packages/core/src/system-record-objects-v1.ts index 36b3512555..e328c9798d 100644 --- a/packages/core/src/system-record-objects-v1.ts +++ b/packages/core/src/system-record-objects-v1.ts @@ -22,6 +22,7 @@ import { keccak256 } from './crypto/keccak.js'; import { workspaceAgentEncryptionKeyId } from './crypto/workspace-encryption.js'; import { assertCanonicalSystemRecordPeerIdV1, + copyBoundedSystemRecordBytesV1, decodeUnpaddedBase64UrlV1, digestSystemRecordBytesV1, failSystemRecordObjectV1 as fail, @@ -99,6 +100,7 @@ const REQUEST_RECORD_KIND = SYSTEM_RECORD_KIND_V1; export type CanonicalRfc3339SecondsV1 = string & { readonly __rfc3339SecondsV1: true }; export { assertCanonicalSystemRecordPeerIdV1, + copyBoundedSystemRecordBytesV1, decodeUnpaddedBase64UrlV1, digestSystemRecordBytesV1, SystemRecordObjectErrorV1, @@ -1261,6 +1263,7 @@ export function evaluateAgentProfileHeadAdvanceV1( } const summary = evidence.verifiedAuthoritySummary; if (!(summary instanceof AgentProfileVerifiedAuthoritySummaryValueV1) + || !MINTED_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARIES_V1.has(summary) || summary.candidateHeadDigest !== candidateDigest) { return { decision: 'reject', reason: 'cold noninitial head requires its verified authority closure' }; } @@ -1557,16 +1560,26 @@ export interface SystemRecordVerificationClosureV1 { readonly authoritySummary: AgentProfileVerifiedAuthoritySummaryV1; } +const MINT_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARY_V1 = Symbol('mint-agent-profile-verified-authority-summary-v1'); +const MINTED_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARIES_V1 = new WeakSet(); + class AgentProfileVerifiedAuthoritySummaryValueV1 { private declare readonly __opaqueAgentProfileVerifiedAuthoritySummaryV1: void; constructor( + token: typeof MINT_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARY_V1, public readonly candidateHeadDigest: Digest32V1, public readonly transitionLineage: readonly AgentProfileAppliedTransitionV1[], public readonly historicalRoots: readonly string[], + /** Prior head bound by the latest verified authority transition, if any. */ + public readonly lastAuthorityTransitionPriorHeadDigest?: Digest32V1, public readonly tombstonePredecessor?: AgentProfileActiveHeadObjectV1, public readonly deletionTableDigest?: Digest32V1, ) { + if (token !== MINT_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARY_V1) { + fail('system-record-closure', 'verified authority summary is factory-only'); + } + MINTED_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARIES_V1.add(this); Object.freeze(this); } } @@ -1578,6 +1591,20 @@ class AgentProfileVerifiedAuthoritySummaryValueV1 { */ export type AgentProfileVerifiedAuthoritySummaryV1 = AgentProfileVerifiedAuthoritySummaryValueV1; +/** + * Runtime authority check for storage bridges that cannot rely on the opaque + * TypeScript type alone. Structural copies are intentionally rejected. + */ +export function assertAgentProfileVerifiedAuthoritySummaryV1( + value: unknown, +): asserts value is AgentProfileVerifiedAuthoritySummaryV1 { + if (value === null || typeof value !== 'object' + || !MINTED_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARIES_V1.has(value) + || !(value instanceof AgentProfileVerifiedAuthoritySummaryValueV1)) { + fail('system-record-closure', 'verified authority summary was not minted by closure verification'); + } +} + export interface SystemRecordClosureArtifactV1 { readonly objectKind: SystemRecordObjectKindV1; readonly digest: Digest32V1; @@ -1948,10 +1975,18 @@ export async function buildAgentProfileVerificationClosureV1( if (tombstonePredecessor !== undefined && tombstonePredecessor.state !== 'active') { fail('system-record-history', 'verified tombstone closure lost its active predecessor'); } + const latestTransition = current.acceptedTransitionDigest === undefined + ? undefined + : parsedTransitions.get(current.acceptedTransitionDigest); + if (current.authoritySequence !== '0' && latestTransition === undefined) { + fail('system-record-history', 'verified closure lost its latest authority transition'); + } return new AgentProfileVerifiedAuthoritySummaryValueV1( + MINT_AGENT_PROFILE_VERIFIED_AUTHORITY_SUMMARY_V1, currentHeadDigest, Object.freeze(reverseLineage.reverse()), Object.freeze(reverseRoots.reverse()), + latestTransition?.priorHeadDigest, tombstonePredecessor?.state === 'active' ? tombstonePredecessor : undefined, tombstonePredecessor?.ownedSubjectTableDigest, ); diff --git a/packages/core/src/system-record-v1.ts b/packages/core/src/system-record-v1.ts index 2dc01d5dc8..3edec56ecc 100644 --- a/packages/core/src/system-record-v1.ts +++ b/packages/core/src/system-record-v1.ts @@ -9,3 +9,5 @@ export * from './system-record-objects-v1.js'; export * from './system-record-applied-state-v1.js'; export * from './system-record-inventory-v1.js'; export * from './system-record-wire-v1.js'; +export { assertNetworkIdV1, type NetworkIdV1 } from './sync-wire-identifiers.js'; +export { assertCanonicalDigest, type Digest32V1 } from './sync-wire-scalars.js'; diff --git a/packages/core/test/system-record-applied-state-v1.test.ts b/packages/core/test/system-record-applied-state-v1.test.ts index 9f2ca820bc..f92453fee3 100644 --- a/packages/core/test/system-record-applied-state-v1.test.ts +++ b/packages/core/test/system-record-applied-state-v1.test.ts @@ -4,13 +4,17 @@ import { canonicalizeSystemRecordAppliedStateV1, canonicalizeSystemRecordCapacityStateV1, canonicalizeSystemRecordMaterializationReceiptV1, + canonicalizeSystemRecordRootClaimSetV1, computeSystemRecordAccountedBytesV1, computeSystemRecordAppliedStateDigestV1, + computeSystemRecordCapacityStateDigestV1, + computeSystemRecordMaterializationReceiptDigestV1, computeSystemRecordRootClaimSetDigestV1, parseCanonicalSystemRecordAppliedStateV1, parseCanonicalSystemRecordCapacityStateV1, parseCanonicalSystemRecordMaterializationReceiptV1, systemRecordAppliedStateAbsentV1, + SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, type SystemRecordAppliedStatePresentV1, } from '../src/system-record-applied-state-v1.js'; import { computeSystemRecordStableKeyHashV1 } from '../src/system-record-inventory-v1.js'; @@ -93,6 +97,51 @@ describe('system-record applied-state codecs', () => { })).not.toThrow(); }); + it('pins canonical persisted-object bytes and digests as cross-version vectors', () => { + const state = activeState(); + const claims = { + objectType: 'system-record-root-claim-set', kind: 'agents', networkId: 'otp:20430', + stableKeyHash: STABLE_KEY, currentRoot: ROOT_A, historicalRoots: [], + } as const; + const capacity = { + objectType: 'system-record-capacity-state', kind: 'agents', networkId: 'otp:20430', + revision: '1', liveRecordCount: '1', stateBytes: '1024', tableBytes: '80', + projectionBytes: '4096', projectionQuads: '3', + } as const; + const receipt = { + objectType: 'system-record-materialization-receipt', kind: 'agents', networkId: 'otp:20430', + stableKeyHash: STABLE_KEY, stateRevision: '1', + appliedStateDigest: '0x0d16b0303417641e8294a1d63fcfaa9cc5872de1d4db50d4ca25886e2ceff0d2', + headDigest: HASH_A, materializationEpoch: '2', + } as const; + const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + + expect(decode(canonicalizeSystemRecordAppliedStateV1(state))).toBe( + '{"accountedBytes":"69712","conflictDigestSlots":[],"conflictOverflow":false,"currentRoot":"did:dkg:agent:0x1111111111111111111111111111111111111111","headDigest":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","historicalRoots":[],"kind":"agents","materializationEpoch":"2","networkId":"otp:20430","objectType":"system-record-applied-state","ownedSubjectCount":"1","ownedSubjectTableBytes":"80","ownedSubjectTableDigest":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","peerId":"12D3KooWJ1TsijH7H5F74hfAD5XishQz3sxrmAtVY37GtNd9CqYf","projectionBytes":"4096","projectionDigest":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","projectionQuads":"3","rootClaimSetDigest":"0xae363b4306d08b33f900c57a4c5ca0bc8e9f5812d8542f034f42f46800feaf75","stableKeyHash":"0xf7e783a2873b287221fb826ca7c83a1adf5dadd785f6c39d6b2ff1fe1640a32e","state":"present","stateRevision":"1","status":"active","transitionLineage":[]}', + ); + expect(computeSystemRecordAppliedStateDigestV1(state)).toBe( + '0x0d16b0303417641e8294a1d63fcfaa9cc5872de1d4db50d4ca25886e2ceff0d2', + ); + expect(decode(canonicalizeSystemRecordCapacityStateV1(capacity))).toBe( + '{"kind":"agents","liveRecordCount":"1","networkId":"otp:20430","objectType":"system-record-capacity-state","projectionBytes":"4096","projectionQuads":"3","revision":"1","stateBytes":"1024","tableBytes":"80"}', + ); + expect(computeSystemRecordCapacityStateDigestV1(capacity)).toBe( + '0x01382cb6928f5c893f42cc883a575ac70293faaddafa2b41a220639241518954', + ); + expect(decode(canonicalizeSystemRecordRootClaimSetV1(claims))).toBe( + '{"currentRoot":"did:dkg:agent:0x1111111111111111111111111111111111111111","historicalRoots":[],"kind":"agents","networkId":"otp:20430","objectType":"system-record-root-claim-set","stableKeyHash":"0xf7e783a2873b287221fb826ca7c83a1adf5dadd785f6c39d6b2ff1fe1640a32e"}', + ); + expect(computeSystemRecordRootClaimSetDigestV1(claims)).toBe( + '0xae363b4306d08b33f900c57a4c5ca0bc8e9f5812d8542f034f42f46800feaf75', + ); + expect(decode(canonicalizeSystemRecordMaterializationReceiptV1(receipt))).toBe( + '{"appliedStateDigest":"0x0d16b0303417641e8294a1d63fcfaa9cc5872de1d4db50d4ca25886e2ceff0d2","headDigest":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"agents","materializationEpoch":"2","networkId":"otp:20430","objectType":"system-record-materialization-receipt","stableKeyHash":"0xf7e783a2873b287221fb826ca7c83a1adf5dadd785f6c39d6b2ff1fe1640a32e","stateRevision":"1"}', + ); + expect(computeSystemRecordMaterializationReceiptDigestV1(receipt)).toBe( + '0xf984b56d3f87bcb5301798ab365a69dce9e7c8803550803a2425047d2b38f63e', + ); + }); + it('binds a root-claim-set digest into present state', () => { const claims = { objectType: 'system-record-root-claim-set', kind: 'agents', networkId: 'otp:20430', @@ -141,8 +190,12 @@ describe('system-record applied-state codecs', () => { }); it('round-trips terminal tombstone state and materialization receipts', () => { + expect(SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1).toBe( + '0x4d798c66290f2feed54b20ad25eab62df38360cab298332be5e6d921ad1b5f3c', + ); const tombstone = { ...activeState(), status: 'tombstone' as const, + projectionDigest: SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, projectionBytes: '0' as const, projectionQuads: '0' as const, ownedSubjectTableDigest: EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, ownedSubjectCount: '0' as const, ownedSubjectTableBytes: '0' as const, @@ -151,6 +204,14 @@ describe('system-record applied-state codecs', () => { expect(parseCanonicalSystemRecordAppliedStateV1( canonicalizeSystemRecordAppliedStateV1(tombstone), )).toEqual(tombstone); + expect(() => canonicalizeSystemRecordAppliedStateV1({ + ...tombstone, + projectionDigest: HASH_B, + })).toThrow(/canonical empty projection/); + expect(() => canonicalizeSystemRecordAppliedStateV1({ + ...activeState(), + projectionDigest: SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, + })).toThrow(/nonempty projection/); const receipt = { objectType: 'system-record-materialization-receipt', kind: 'agents', networkId: 'otp:20430', stableKeyHash: STABLE_KEY, stateRevision: '1', appliedStateDigest: HASH_A, diff --git a/packages/core/test/system-record-limits-v1.test.ts b/packages/core/test/system-record-limits-v1.test.ts index 5fa8ea9622..3519a40fac 100644 --- a/packages/core/test/system-record-limits-v1.test.ts +++ b/packages/core/test/system-record-limits-v1.test.ts @@ -32,6 +32,14 @@ describe('frozen system-record V1 limits', () => { expect(limits.SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES).toBe(64 * 1024); expect(limits.SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES).toBe(512 * 1024 * 1024); expect(limits.SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_QUADS).toBe(5_000_000); + expect(limits.SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES).toBe(4 * 1024 * 1024); + expect(limits.SYSTEM_RECORD_MAX_ATOMIC_INSPECTION_RESPONSE_BYTES).toBe(4 * 1024 * 1024); + expect(limits.SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES).toBe(1024 * 1024); + expect(limits.SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES).toBe(8 * 1024 * 1024); + expect(limits.SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES).toBe(12 * 1024 * 1024); + expect(limits.SYSTEM_RECORD_APPLY_TIMEOUT_MS).toBe(1_000); + expect(limits.SYSTEM_RECORD_INSPECTION_TIMEOUT_MS).toBe(1_000); + expect(limits.SYSTEM_RECORD_REQUIRED_DISPATCH_BUDGET_MS).toBe(1_500); }); it('pins aggregate cache, activation, runtime, continuation, and journal ceilings', () => { diff --git a/packages/core/test/system-record-objects-v1.test.ts b/packages/core/test/system-record-objects-v1.test.ts index d183d52a0c..fedb3af50a 100644 --- a/packages/core/test/system-record-objects-v1.test.ts +++ b/packages/core/test/system-record-objects-v1.test.ts @@ -888,6 +888,7 @@ describe('system-record owned subjects and verification closure', () => { candidateHeadDigest: computeAgentProfileHeadObjectDigestV1(tombstone), deletionTableDigest: middle.ownedSubjectTableDigest, historicalRoots: [initial.rootSubject], + lastAuthorityTransitionPriorHeadDigest: transition.priorHeadDigest, }); }); diff --git a/packages/storage/src/adapters/managed-http-client.ts b/packages/storage/src/adapters/managed-http-client.ts index a18e39834c..c5f8370da7 100644 --- a/packages/storage/src/adapters/managed-http-client.ts +++ b/packages/storage/src/adapters/managed-http-client.ts @@ -1,5 +1,8 @@ import { Agent, request as httpRequest, type IncomingMessage } from 'node:http'; -import { SYSTEM_RECORD_MAX_MATERIALIZER_WRITE_CONCURRENCY } from '@origintrail-official/dkg-core/system-record-v1'; +import { performance } from 'node:perf_hooks'; +import { + SYSTEM_RECORD_MAX_MATERIALIZER_WRITE_CONCURRENCY, +} from '@origintrail-official/dkg-core/system-record-v1'; /** * A connection pool the managed lane actually OWNS (#2052 Stack B2). @@ -27,6 +30,50 @@ import { SYSTEM_RECORD_MAX_MATERIALIZER_WRITE_CONCURRENCY } from '@origintrail-o export interface ManagedHttpResponse { readonly status: number; readonly body: string; + /** Internal accountant hook installed by the atomic executor wrapper. */ + readonly replaceRetainedBytes?: (bytes: number) => void; +} + +/** + * Per-call body limits for the managed materializer transport. + * + * They are optional on `post()` only to preserve the B2 caller while the B3 + * transaction executor is introduced. A caller that supplies limits must + * supply both: silently bounding only one direction would leave the other + * allocation unbounded while making the call look protected. + */ +export interface ManagedHttpBodyLimits { + readonly maxRequestBytes: number; + readonly maxResponseBytes: number; + /** + * Called before the transport allocates or grows its bounded encoded response + * buffer. The argument is the new total buffer capacity, not a delta. + */ + readonly reserveResponseCapacity?: (capacityBytes: number) => void; +} + +interface ManagedHttpDispatchV1 { + readonly result: Promise; + readonly physicalSettlement: Promise; +} + +function assertByteLimit(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative safe integer`); + } +} + +const INITIAL_CHUNKED_RESPONSE_CAPACITY = 64 * 1024; + +function boundedResponseCapacity(current: number, required: number, maximum: number): number { + if (required > maximum) { + throw new Error(`managed SPARQL response body exceeded ${maximum} bytes`); + } + if (current >= required) return current; + const doubled = current === 0 + ? Math.min(INITIAL_CHUNKED_RESPONSE_CAPACITY, maximum) + : current > Math.floor(maximum / 2) ? maximum : current * 2; + return Math.min(maximum, Math.max(required, doubled)); } export class OwnedManagedHttpClient { @@ -80,6 +127,7 @@ export class OwnedManagedHttpClient { body: string, timeoutMs: number, signal?: AbortSignal, + limits?: ManagedHttpBodyLimits, ): Promise { if (this.destroyed) { throw new Error( @@ -87,24 +135,56 @@ export class OwnedManagedHttpClient { ); } - const work = this.dispatch(url, contentType, body, timeoutMs, signal); - this.inflight.add(work); - try { - return await work; - } finally { - this.inflight.delete(work); + if (limits) { + assertByteLimit('managed HTTP maxRequestBytes', limits.maxRequestBytes); + assertByteLimit('managed HTTP maxResponseBytes', limits.maxResponseBytes); + } + if (signal?.aborted) { + throw new Error('managed SPARQL request aborted'); + } + + // Measure the immutable string before Buffer.from() allocates the payload. + // This is deliberately outside dispatch: a one-byte-over request must not + // create an HTTP request, enter the Agent queue, or allocate a second copy. + const requestBytes = Buffer.byteLength(body, 'utf8'); + if (limits && requestBytes > limits.maxRequestBytes) { + throw new Error( + `managed SPARQL request body is ${requestBytes} bytes; ` + + `maximum is ${limits.maxRequestBytes} bytes`, + ); } + + const work = this.dispatch( + url, + contentType, + body, + requestBytes, + timeoutMs, + signal, + limits?.maxResponseBytes, + limits?.reserveResponseCapacity, + ); + this.inflight.add(work.physicalSettlement); + void work.physicalSettlement.then(() => this.inflight.delete(work.physicalSettlement)); + return await work.result; } private dispatch( url: string, contentType: string, body: string, + requestBytes: number, timeoutMs: number, signal?: AbortSignal, - ): Promise { - return new Promise((resolve, reject) => { - const payload = Buffer.from(body, 'utf8'); + maxResponseBytes?: number, + reserveResponseCapacity?: (capacityBytes: number) => void, + ): ManagedHttpDispatchV1 { + let settlePhysical!: () => void; + const physicalSettlement = new Promise((resolve) => { + settlePhysical = resolve; + }); + const result = new Promise((resolve, reject) => { + let payload: Buffer | null = Buffer.from(body, 'utf8'); /** * Wall-clock deadline over the WHOLE call, armed before the request is @@ -124,54 +204,240 @@ export class OwnedManagedHttpClient { * as a SAFETY bound, so it has to cover queue wait too. */ let request: ReturnType | undefined; + let response: IncomingMessage | undefined; let settled = false; - const deadline = setTimeout(() => { - const expiry = new Error(`managed SPARQL request exceeded ${timeoutMs}ms`); - // Settle FIRST, then tear down. Destroying a request that has not yet - // been assigned a socket does not emit `error` until one arrives, so - // relying on the teardown to reject leaves the caller blocked long past - // its deadline: measured 3825 ms for a 500 ms timeout, with the right - // error message and the wrong latency. The deadline must bound when the - // CALLER is released, not merely when we start cleaning up. - fail(expiry); - request?.destroy(expiry); - }, timeoutMs); + let physicalSettled = false; + let deadline: NodeJS.Timeout | undefined; + let onAbort: (() => void) | undefined; + let cleanupResponse = () => undefined; + + const completePhysicalSettlement = () => { + if (physicalSettled) return; + physicalSettled = true; + settlePhysical(); + }; + + const cleanupSettlement = () => { + if (deadline) { + clearTimeout(deadline); + deadline = undefined; + } + if (signal && onAbort) { + signal.removeEventListener('abort', onAbort); + onAbort = undefined; + } + }; const succeed = (value: ManagedHttpResponse) => { if (settled) return; settled = true; - clearTimeout(deadline); + cleanupSettlement(); + payload = null; + cleanupResponse(); + completePhysicalSettlement(); resolve(value); }; const fail = (error: unknown) => { if (settled) return; settled = true; - clearTimeout(deadline); + cleanupSettlement(); reject(error); }; - const req = httpRequest( - url, - { - method: 'POST', - agent: this.agent, - headers: { - 'Content-Type': contentType, - 'Content-Length': payload.byteLength, - }, - }, - (res: IncomingMessage) => { - const chunks: Buffer[] = []; - res.on('data', (chunk: Buffer) => chunks.push(chunk)); - res.on('end', () => { - succeed({ - status: res.statusCode ?? 0, - body: Buffer.concat(chunks).toString('utf8'), - }); - }); - res.on('error', fail); - }, + const terminate = (error: Error) => { + if (settled) return; + // Release every owned body reference synchronously, initiate teardown, + // then settle the caller. The separate physical promise remains in the + // client-owned set until the request close event proves teardown. + payload = null; + cleanupResponse(); + response?.destroy(); + request?.destroy(); + fail(error); + if (!request) completePhysicalSettlement(); + }; + + deadline = setTimeout( + () => terminate(new Error(`managed SPARQL request exceeded ${timeoutMs}ms`)), + timeoutMs, ); + + let req: ReturnType; + try { + req = httpRequest( + url, + { + method: 'POST', + agent: this.agent, + headers: { + 'Content-Type': contentType, + 'Content-Length': requestBytes, + }, + }, + (res: IncomingMessage) => { + response = res; + const status = res.statusCode; + let responseBuffer: Buffer | null = null; + let responseChunks: Buffer[] | null = maxResponseBytes === undefined ? [] : null; + let responseBytes = 0; + + const failResponse = (error: Error) => terminate(error); + const onResponseError = (error: Error) => terminate(error); + const onResponseAborted = () => + terminate(new Error('managed SPARQL response aborted before completion')); + const onResponseClose = () => { + if (!res.complete) { + terminate(new Error('managed SPARQL response closed before completion')); + } + }; + const onResponseData = (chunk: Buffer | string) => { + if (settled) return; + const encoded = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk; + const chunkBytes = encoded.byteLength; + if (maxResponseBytes !== undefined + && chunkBytes > maxResponseBytes - responseBytes) { + failResponse( + new Error( + `managed SPARQL response body exceeded ${maxResponseBytes} bytes`, + ), + ); + return; + } + if (declaredLength !== undefined && chunkBytes > declaredLength - responseBytes) { + failResponse(new Error('managed SPARQL response exceeded its Content-Length')); + return; + } + if (maxResponseBytes !== undefined) { + const requiredCapacity = responseBytes + chunkBytes; + if (responseBuffer === null || requiredCapacity > responseBuffer.byteLength) { + // Content-Length-free responses grow one owned buffer. The + // accountant sees the new TOTAL capacity before allocation; + // its conservative 3x weighting covers old + new buffers and + // the incoming chunk while copying, as well as buffer + final + // two-byte-weighted JS string during conversion. + const capacity = boundedResponseCapacity( + responseBuffer?.byteLength ?? 0, + requiredCapacity, + maxResponseBytes, + ); + try { + reserveResponseCapacity?.(capacity); + const grown = Buffer.allocUnsafe(capacity); + responseBuffer?.copy(grown, 0, 0, responseBytes); + responseBuffer = grown; + } catch (cause) { + failResponse(new Error('managed SPARQL response buffer growth failed', { cause })); + return; + } + } + } + if (responseBuffer) encoded.copy(responseBuffer, responseBytes); + else responseChunks?.push(encoded); + responseBytes += chunkBytes; + }; + const onResponseEnd = () => { + if (settled) return; + if (declaredLength !== undefined && declaredLength !== responseBytes) { + terminate( + new Error( + `managed SPARQL response body received ${responseBytes} bytes; ` + + `Content-Length declared ${declaredLength} bytes`, + ), + ); + return; + } + try { + const encoded = maxResponseBytes === undefined + ? Buffer.concat(responseChunks ?? [], responseBytes) + : responseBuffer ?? Buffer.alloc(0); + const responseBody = encoded.toString('utf8', 0, responseBytes); + responseBuffer = null; + responseChunks = null; + succeed({ status: status as number, body: responseBody }); + } catch (cause) { + terminate(new Error('managed SPARQL response conversion failed', { cause })); + } + }; + cleanupResponse = () => { + responseBuffer = null; + responseChunks = null; + res.removeListener('error', onResponseError); + res.removeListener('aborted', onResponseAborted); + res.removeListener('close', onResponseClose); + res.removeListener('data', onResponseData); + res.removeListener('end', onResponseEnd); + }; + res.on('error', onResponseError); + res.on('aborted', onResponseAborted); + res.on('close', onResponseClose); + + if ( + status === undefined || + !Number.isSafeInteger(status) || + status < 100 || + status > 999 + ) { + failResponse(new Error('managed SPARQL response has an invalid HTTP status')); + return; + } + + const contentLengthHeader = res.headers['content-length']; + let declaredLength: number | undefined; + if (contentLengthHeader !== undefined) { + if ( + Array.isArray(contentLengthHeader) || + !/^\d+$/.test(contentLengthHeader) || + !Number.isSafeInteger(Number(contentLengthHeader)) + ) { + failResponse(new Error('managed SPARQL response has an invalid Content-Length')); + return; + } + declaredLength = Number(contentLengthHeader); + if (maxResponseBytes !== undefined && declaredLength > maxResponseBytes) { + failResponse( + new Error( + `managed SPARQL response body declares ${declaredLength} bytes; ` + + `maximum is ${maxResponseBytes} bytes`, + ), + ); + return; + } + } + if (maxResponseBytes !== undefined && declaredLength !== undefined) { + // A trustworthy Content-Length can allocate exactly once. Chunked + // responses grow below as bytes arrive; reserving their whole + // 4-MiB protocol ceiling here would consume a 12-MiB weighted lease + // even when the actual body is only a few bytes. + const capacity = declaredLength; + try { + reserveResponseCapacity?.(capacity); + } catch (cause) { + failResponse(new Error('managed SPARQL response exceeds its transient byte bound', { + cause, + })); + return; + } + try { + responseBuffer = Buffer.allocUnsafe(capacity); + } catch (cause) { + failResponse(new Error('managed SPARQL response buffer allocation failed', { cause })); + return; + } + } + + res.on('data', onResponseData); + res.on('end', onResponseEnd); + }, + ); + } catch (error) { + payload = null; + cleanupResponse(); + completePhysicalSettlement(); + fail(error); + return; + } request = req; + const onRequestError = (error: Error) => terminate(error); + req.once('close', completePhysicalSettlement); // Socket-level idle timeout, kept ALONGSIDE the wall-clock deadline // above rather than instead of it: this one detects a connection that @@ -180,22 +446,27 @@ export class OwnedManagedHttpClient { // this request's socket, which is what makes a timed-out write // indeterminate rather than silently retryable on a live connection. req.setTimeout(timeoutMs, () => { - req.destroy(new Error(`managed SPARQL request exceeded ${timeoutMs}ms`)); + terminate(new Error(`managed SPARQL request exceeded ${timeoutMs}ms`)); }); - req.on('error', fail); + req.on('error', onRequestError); - const onAbort = () => req.destroy(new Error('managed SPARQL request aborted')); + onAbort = () => terminate(new Error('managed SPARQL request aborted')); if (signal) { if (signal.aborted) { - req.destroy(new Error('managed SPARQL request aborted')); + onAbort(); return; } signal.addEventListener('abort', onAbort, { once: true }); - req.on('close', () => signal.removeEventListener('abort', onAbort)); } - req.end(payload); + try { + req.end(payload); + payload = null; + } catch (error) { + terminate(error instanceof Error ? error : new Error(String(error))); + } }); + return Object.freeze({ result, physicalSettlement }); } /** @@ -222,6 +493,18 @@ export class OwnedManagedHttpClient { * caller must fail closed instead of binding a replacement. */ async destroyAndSettle(timeoutMs = 5_000): Promise { + return this.destroyAndSettleUntil(performance.now() + timeoutMs); + } + + /** + * Deadline-sharing form used by lifecycle recovery. The absolute monotonic + * deadline was minted before child retirement, so socket cleanup consumes + * the SAME budget as stop/prove, replacement start and exact reconciliation. + */ + async destroyAndSettleUntil(absoluteDeadlineMs: number): Promise { + if (!Number.isFinite(absoluteDeadlineMs)) { + throw new Error('managed HTTP settlement deadline must be finite'); + } this.destroyed = true; // Destroy BEFORE settling, not after. Destroying the agent tears down @@ -239,22 +522,21 @@ export class OwnedManagedHttpClient { // on a HEALTHY-looking endpoint rather than a dead one. this.agent.destroy(); - const deadline = Date.now() + timeoutMs; - // The settle now shares the deadline too, so the terminal-failure branch - // below is reachable rather than being guarded by an unbounded await. - await Promise.race([ - Promise.allSettled([...this.inflight]), - new Promise((resolve) => setTimeout(resolve, timeoutMs).unref?.()), - ]); - while (this.openSocketCount > 0) { - if (Date.now() > deadline) { + // Poll the owned set itself instead of racing a snapshot of its promises. + // `Promise.race` would return at the deadline while the request promises + // kept running behind a released lifecycle barrier. Destroy forces each + // request to settle; this loop joins their `finally` cleanup and the socket + // close events before reporting success. + while (this.inflight.size > 0 || this.openSocketCount > 0) { + if (performance.now() >= absoluteDeadlineMs) { throw new Error( `managed HTTP client for child generation ${this.generation} still holds ` + - `${this.openSocketCount} socket(s) ${timeoutMs}ms after destroy; ` + + `${this.openSocketCount} socket(s) and ${this.inflight.size} request(s) after destroy; ` + 'the retired generation cannot be proven dead', ); } - await new Promise((r) => setTimeout(r, 10)); + const remainingMs = absoluteDeadlineMs - performance.now(); + await new Promise((r) => setTimeout(r, Math.max(1, Math.min(10, remainingMs)))); // Re-destroy is idempotent and reaps sockets that became free after the // first call (a response that completed while we were waiting). this.agent.destroy(); diff --git a/packages/storage/src/adapters/sparql-http.ts b/packages/storage/src/adapters/sparql-http.ts index 40e56e2fdc..0554417385 100644 --- a/packages/storage/src/adapters/sparql-http.ts +++ b/packages/storage/src/adapters/sparql-http.ts @@ -38,7 +38,10 @@ import { formatSparqlJsonBindings, type AdapterSparqlJsonSelectResponse, } from './sparql-json-results.js'; -import { externalStorePriorityScheduler } from '../store-priority-scheduler.js'; +import { + externalStorePriorityScheduler, + type StoreAdmissionV1, +} from '../store-priority-scheduler.js'; import { GraphWriteGenTracker } from '../graph-write-gen.js'; import { NON_EMPTY_NAMED_GRAPH_ENUMERATION_QUERY } from './graph-enumeration-query.js'; import { @@ -52,17 +55,23 @@ import { assertNotReservedInternalGraphV1 } from '../internal-graph-policy.js'; import { extractManagedOxigraphHandoffV1, extractManagedOxigraphLeaseV1, + managedOxigraphOwnershipEndpointsMatchV1, readManagedOxigraphOwnershipSnapshotV1, type ManagedOxigraphOwnershipLeaseV1, type ManagedOxigraphSupervisorHandoffV1, } from '../managed-oxigraph-ownership-v1-internal.js'; import { createSystemRecordLaneControllerV1, + disposeSystemRecordLaneControllerV1, type SystemRecordApplyOutcomeV1, type SystemRecordChildHandoffV1, type SystemRecordLaneControllerV1, + 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 { OwnedManagedHttpClient } from './managed-http-client.js'; +import { rotateSystemRecordMaterializationEpochV1 } from '../system-record-materialization-epoch-v1-internal.js'; import { UnsupportedTripleStoreCapabilityError } from '../unsupported-capability-error.js'; import { readResponseTextBounded } from '../http-response-limit.js'; import { @@ -103,6 +112,25 @@ const DEFAULT_SLOW_QUERY_THRESHOLD_MS = 10_000; const DEFAULT_SLOW_QUERY_SAMPLE_RATE = 1; const MANAGED_LIST_GRAPHS_CACHE_MS = 30_000; const monotonicNow = (): number => performance.now(); +const CONTEXT_GRAPH_IRI_PREFIX = 'did:dkg:context-graph:'; +const SYSTEM_CONTEXT_GRAPH_IRIS = [ + `${CONTEXT_GRAPH_IRI_PREFIX}agents`, + `${CONTEXT_GRAPH_IRI_PREFIX}ontology`, +] as const; + +interface ManagedMutationBindingV1 { + readonly admission: StoreAdmissionV1; + readonly generation: string; +} + +export class ManagedOxigraphMutationUnavailableError extends Error { + readonly code = 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE' as const; + + constructor(reason: string) { + super(`managed Oxigraph mutation is unavailable: ${reason}`); + this.name = 'ManagedOxigraphMutationUnavailableError'; + } +} export interface SparqlHttpQueryOptions extends QueryOptions { /** Caller tag used in slow-query telemetry, e.g. `agent.listContextGraphs`. */ @@ -167,6 +195,10 @@ export class SparqlHttpStore implements TripleStore { private readonly queryEndpoint: string; private readonly updateEndpoint: string; + /** Raw endpoint facts used only for exact ownership-lease identity matching. */ + private readonly systemRecordQueryEndpoint: string; + private readonly systemRecordUpdateEndpoint: string; + private readonly systemRecordHasCredentials: boolean; private readonly timeout: number; private readonly headers: Record; private readonly managedByDkg: boolean; @@ -196,15 +228,28 @@ export class SparqlHttpStore implements TripleStore { private readonly supervisorHandoff: ManagedOxigraphSupervisorHandoffV1 | null; /** Lazily built so a store that is never asked for the lane allocates nothing. */ private systemRecordLane: SystemRecordLaneControllerV1 | null | undefined; + /** Registered controller behind the adapter facade; disposal must use this exact identity. */ + private systemRecordLaneOwner: SystemRecordLaneControllerV1 | null = null; + /** + * Ordinary mutations stay on the existing untagged path until activation + * intent synchronously claims admission. The control barrier already waits + * for untagged in-flight work, so disabled mode needs no per-write metadata. + */ + private systemRecordAdmissionActive = false; /** The owned pool for the CURRENT child generation. */ private managedClient: OwnedManagedHttpClient | null = null; /** A pool retired by `destroyClient`, still awaiting drain by `awaitRetiredWork`. */ private retiredClient: OwnedManagedHttpClient | null = null; + /** Terminal lifecycle fault: once set, no ordinary managed mutation may dispatch. */ + private managedMutationFailure: string | null = null; constructor(options: SparqlHttpStoreOptions) { if (!options.queryEndpoint?.trim()) { throw new Error('sparql-http adapter requires options.queryEndpoint'); } + this.systemRecordQueryEndpoint = options.queryEndpoint; + this.systemRecordUpdateEndpoint = options.updateEndpoint ?? options.queryEndpoint; + this.systemRecordHasCredentials = options.auth !== undefined; this.queryEndpoint = options.queryEndpoint.replace(/\/$/, ''); this.updateEndpoint = (options.updateEndpoint ?? options.queryEndpoint).replace(/\/$/, ''); this.timeout = options.timeout ?? 30_000; @@ -239,18 +284,109 @@ export class SparqlHttpStore implements TripleStore { operation: string, options: QueryOptions | undefined, work: (signal: AbortSignal | undefined) => Promise, + mutationBinding?: ManagedMutationBindingV1, ): Promise { return this.workLifecycle.run( options?.signal, (signal) => externalStorePriorityScheduler.run( options?.priority, options?.source ?? `sparql-http.${operation}`, - () => work(signal), + () => { + if (mutationBinding) this.assertManagedMutationBinding(mutationBinding); + return work(signal); + }, signal, + mutationBinding?.admission, ), ); } + /** + * Bind one managed mutation to the exact live child generation before it is + * admitted. Ordinary/operator-configured SPARQL endpoints keep the legacy + * untagged fast path. + */ + private createManagedMutationBinding( + graphs: Iterable | undefined, + ): ManagedMutationBindingV1 | undefined { + if (!this.ownershipLease) return undefined; + if (this.managedMutationFailure !== null) { + throw new ManagedOxigraphMutationUnavailableError(this.managedMutationFailure); + } + if (!this.systemRecordAdmissionActive) return undefined; + const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + if ( + !snapshot || + snapshot.terminal || + !snapshot.ready || + this.systemRecordHasCredentials || + !managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ) + ) { + throw new ManagedOxigraphMutationUnavailableError('ownership is not live and attributable'); + } + + const domain = this.managedMutationDomain(graphs); + return Object.freeze({ + generation: snapshot.childGeneration, + admission: Object.freeze({ + storeId: this, + generation: snapshot.childGeneration, + domain, + mode: 'shared' as const, + }), + }); + } + + /** Recheck after queueing and immediately before any update byte can leave. */ + private assertManagedMutationBinding(binding: ManagedMutationBindingV1): void { + if (this.managedMutationFailure !== null) { + throw new ManagedOxigraphMutationUnavailableError(this.managedMutationFailure); + } + if (!this.ownershipLease) { + throw new ManagedOxigraphMutationUnavailableError('ownership lease was revoked'); + } + const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + if ( + !snapshot || + snapshot.terminal || + !snapshot.ready || + snapshot.childGeneration !== binding.generation || + this.systemRecordHasCredentials || + !managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ) + ) { + throw new ManagedOxigraphMutationUnavailableError('child generation changed before dispatch'); + } + } + + /** + * Explicit, non-system graph scopes stay outside the `agents` ordering + * domain. Unknown/default/system scopes conservatively serialize with the + * system-record apply. Hashing keeps attacker-controlled IRIs out of + * scheduler diagnostics and caps the domain key at a fixed size. + */ + private managedMutationDomain(graphs: Iterable | undefined): string { + if (!graphs) return 'agents'; + const explicit = new Set(); + for (const graph of graphs) { + if (!graph || !graph.startsWith(CONTEXT_GRAPH_IRI_PREFIX)) return 'agents'; + if (SYSTEM_CONTEXT_GRAPH_IRIS.some( + (system) => graph === system || graph.startsWith(`${system}/`), + )) return 'agents'; + explicit.add(graph); + } + if (explicit.size === 0) return 'agents'; + const canonicalScope = [...explicit].sort().join('\n'); + return `cg:${createHash('sha256').update(canonicalScope).digest('hex')}`; + } + getPressureSnapshot(): StorePressureSnapshot { return externalStorePriorityScheduler.snapshot; } @@ -308,10 +444,12 @@ export class SparqlHttpStore implements TripleStore { update: string, options?: QueryOptions, operation = 'update', + graphs?: Iterable, ): Promise { // Direct POST (W3C SPARQL 1.1 Protocol §2.2.2): the update is the raw // request body with `application/sparql-update`, not URL-encoded form // data. See postQuery for why form encoding breaks large payloads. + const mutationBinding = this.createManagedMutationBinding(graphs); return this.runStoreWork(operation, options, async (lifecycleSignal) => { const timeoutSignal = AbortSignal.timeout(this.timeout); const signalScope = composeAbortSignals(lifecycleSignal, timeoutSignal); @@ -343,7 +481,7 @@ export class SparqlHttpStore implements TripleStore { } finally { signalScope.dispose(); } - }); + }, mutationBinding); } /** @@ -362,12 +500,12 @@ export class SparqlHttpStore implements TripleStore { * enumerate: no legitimate iterate-and-drop loop can reach one, only a * hardcoded IRI can. * - * `update()` is deliberately NOT guarded. Its argument is an opaque SPARQL + * `update()` cannot use this graph guard. Its argument is an opaque SPARQL * program, and scanning it for reserved IRIs would be exactly the evadable * best-effort string check that `ChangelogStore.assertNoReservedRef` already - * documents as insufficient. Opaque updates instead rotate the - * materialization epoch; Stack C audits and migrates the remaining raw - * callers before any of this behaviour is enabled. + * documents as insufficient. While system-record admission is active, that + * public raw-update path is refused before dispatch; Stack C can replace the + * refusal after its callers are migrated to an epoch-invalidating boundary. */ private assertGenericMutationScope( graphs: Iterable, @@ -415,16 +553,44 @@ export class SparqlHttpStore implements TripleStore { // still holds the lease object; advertising then would hand out a lane over // a child that is not the proven listener. Absence here is transient by // design, which is why the decorators above re-probe rather than latch it. - if (!snapshot || snapshot.terminal || !snapshot.ready) return undefined; + if ( + !snapshot || + snapshot.terminal || + !snapshot.ready || + this.systemRecordHasCredentials || + !managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ) + ) return undefined; if (this.systemRecordLane === undefined) { try { - this.systemRecordLane = createSystemRecordLaneControllerV1({ + // 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(); + const atomicExecutor = createSystemRecordAtomicApplyExecutorV1({ + consumer, + storeId: this, + queryEndpoint: this.systemRecordQueryEndpoint, + updateEndpoint: this.systemRecordUpdateEndpoint, + resolveClient: (binding) => this.resolveSystemRecordManagedClient(binding), + }); + const owner = createSystemRecordLaneControllerV1({ lease: this.ownershipLease, handoff: this.buildChildHandoff(this.supervisorHandoff), executor: { applyVerified: (proof, childGeneration) => - this.executeSystemRecordApply(proof, childGeneration), + this.executeSystemRecordApplyLegacy(proof, childGeneration), + discardVerified: (proof) => atomicExecutor.discard(proof), + applyVerifiedSettlementBound: (proof, binding, registerRecovery) => + atomicExecutor.execute(proof, binding, registerRecovery), }, // Every lifecycle transition runs under the scheduler's control // barrier, which is what actually makes "the child is stopped only @@ -451,7 +617,12 @@ export class SparqlHttpStore implements TripleStore { ? readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease)?.childGeneration : undefined, ), + setAdmissionActive: (active) => { + this.systemRecordAdmissionActive = active; + }, }); + this.systemRecordLaneOwner = owner; + this.systemRecordLane = owner; } catch { // A capability PROBE must never throw. The factory refuses a second // owned-store registration, and that refusal used to propagate out of @@ -459,6 +630,7 @@ export class SparqlHttpStore implements TripleStore { // call it inside an unguarded memo fill — so merely ASKING whether the // capability existed could take down the caller. Absence is the correct // answer to "can I have the lane?" when one is already registered. + this.systemRecordLaneOwner = null; this.systemRecordLane = null; } } @@ -487,58 +659,133 @@ export class SparqlHttpStore implements TripleStore { // which calls `awaitRetiredWork` with no preceding `destroyClient` and so // would have destroyed the CURRENT client and left the field pointing at // a dead pool bound to the live generation. - destroyClient: async () => { + destroyClient: async (absoluteDeadlineMs) => { const retired = this.managedClient; this.managedClient = null; if (retired) { this.retiredClient = retired; - await retired.destroyAndSettle(); + if (absoluteDeadlineMs === undefined) await retired.destroyAndSettle(); + else await retired.destroyAndSettleUntil(absoluteDeadlineMs); } }, - stopAndProveOwnedChildDead: () => supervisor.stopAndProveOwnedChildDead(), - awaitRetiredWork: async () => { + stopAndProveOwnedChildDead: (absoluteDeadlineMs) => + supervisor.stopAndProveOwnedChildDead(absoluteDeadlineMs), + awaitRetiredWork: async (absoluteDeadlineMs) => { const retired = this.retiredClient; - this.retiredClient = null; - if (retired) await retired.destroyAndSettle(); + if (retired) { + if (absoluteDeadlineMs === undefined) await retired.destroyAndSettle(); + else await retired.destroyAndSettleUntil(absoluteDeadlineMs); + if (this.retiredClient === retired) this.retiredClient = null; + } + }, + startAndProveCleanGeneration: (absoluteDeadlineMs) => + supervisor.startAndProveCleanGeneration(absoluteDeadlineMs), + failManagedMutationsClosed: (reason) => { + this.managedMutationFailure ??= reason; }, - startAndProveCleanGeneration: () => supervisor.startAndProveCleanGeneration(), - rotateMaterializationEpoch: async () => { + rotateMaterializationEpoch: async (networkId) => { + if (networkId === undefined) { + throw new Error('system-record materialization epoch rotation requires a network ID'); + } + if (!this.ownershipLease) { + throw new Error('managed Oxigraph ownership lease is unavailable'); + } + const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + if ( + !snapshot || + snapshot.terminal || + !snapshot.ready || + this.systemRecordHasCredentials || + !managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ) + ) { + throw new Error('managed Oxigraph ownership changed before epoch rotation'); + } + if (!this.managedClient) { + this.managedClient = new OwnedManagedHttpClient(snapshot.childGeneration); + } else if (this.managedClient.childGeneration !== snapshot.childGeneration) { + throw new Error('managed HTTP client is bound to a different child generation'); + } + const rotated = await rotateSystemRecordMaterializationEpochV1({ + networkId, + lease: this.ownershipLease, + client: this.managedClient, + queryEndpoint: this.systemRecordQueryEndpoint, + updateEndpoint: this.systemRecordUpdateEndpoint, + }); this.invalidateListGraphsCache(); this.writeGen.recordUnscopedWrite(); + return rotated; + }, + createRecoveryRuntime: (binding, absoluteDeadlineMs, signal) => { + const client = this.resolveSystemRecordManagedClient(binding); + if (!client) { + throw new Error('managed Oxigraph recovery client is unavailable'); + } + const capturedClient = client; + const capturedGeneration = binding.childGeneration; + return Object.freeze({ + client: capturedClient, + queryEndpoint: this.systemRecordQueryEndpoint, + absoluteDeadlineMs, + signal, + assertAttributable: () => { + if (!this.ownershipLease || capturedClient.isDestroyed) return false; + const current = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + return Boolean( + current && + !current.terminal && + current.ready && + current.childGeneration === capturedGeneration && + this.managedClient === capturedClient && + managedOxigraphOwnershipEndpointsMatchV1( + current, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ), + ); + }, + }); }, }; } - /** - * Dispatch one verified apply against an explicitly named child generation. - * - * The generation is rechecked immediately before any byte can leave, and the - * request goes through a pool owned by exactly that generation, so a stale - * facade cannot reach a replacement listener even if it somehow retained a - * reference. - */ - private async executeSystemRecordApply( - _proof: unknown, - childGeneration: string, - ): Promise { - if (!this.ownershipLease) return { outcome: 'capability-lost' }; + /** Resolve exactly one live generation-owned client, or fail before I/O. */ + private resolveSystemRecordManagedClient( + binding: SystemRecordLaneExecutionBindingV1, + ): OwnedManagedHttpClient | null { + if (!this.ownershipLease || this.systemRecordHasCredentials) return null; const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); - if (!snapshot || snapshot.terminal) return { outcome: 'capability-lost' }; - if (!snapshot.ready || snapshot.childGeneration !== childGeneration) { - return { outcome: 'deferred', reason: 'generation-changed' }; - } - - if (!this.managedClient || this.managedClient.childGeneration !== childGeneration) { - // A pool is bound to one generation for its whole life; a mismatch means - // the caller is holding a facade from a retired generation. - if (this.managedClient) return { outcome: 'capability-lost' }; - this.managedClient = new OwnedManagedHttpClient(childGeneration); + if ( + !snapshot || + snapshot.terminal || + !snapshot.ready || + snapshot.childGeneration !== binding.childGeneration || + !managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ) + ) return null; + if (this.managedClient === null) { + this.managedClient = new OwnedManagedHttpClient(binding.childGeneration); } + if ( + this.managedClient.isDestroyed || + this.managedClient.childGeneration !== binding.childGeneration + ) return null; + return this.managedClient; + } - // The verified-replacement command construction and the full-state CAS - // transaction are the next increment of this stack; until they land the - // lane refuses rather than dispatching an unproven write. - return { outcome: 'deferred', reason: 'validation-mismatch' }; + /** Compatibility-only B2 entry point; production sessions prefer the atomic bound path. */ + private executeSystemRecordApplyLegacy( + _proof: unknown, + _childGeneration: string, + ): Promise { + return Promise.resolve({ outcome: 'deferred', reason: 'validation-mismatch' }); } async insert(quads: DKGQuad[], options?: QueryOptions): Promise { @@ -567,7 +814,7 @@ export class SparqlHttpStore implements TripleStore { await this.postUpdate(update, { ...options, source: options?.source ?? 'sparql-http.insert', - }, 'insert'); + }, 'insert', byGraph.keys()); this.invalidateListGraphsCache(); this.writeGen.recordGraphWrites(byGraph.keys()); } @@ -584,12 +831,13 @@ export class SparqlHttpStore implements TripleStore { // structure over the SPARQL protocol. See the helper for details. const update = buildBlankNodeSafeDelete(quads); if (!update) return; + const graphs = new Set(quads.map((q) => q.graph || '')); await this.postUpdate(update, { ...options, source: options?.source ?? 'sparql-http.delete', - }, 'delete'); + }, 'delete', graphs); this.invalidateListGraphsCache(); - this.writeGen.recordGraphWrites(new Set(quads.map((q) => q.graph || ''))); + this.writeGen.recordGraphWrites(graphs); } async deleteByPattern(pattern: Partial, options?: QueryOptions): Promise { @@ -623,7 +871,7 @@ export class SparqlHttpStore implements TripleStore { await this.postUpdate(update, { ...options, source: options?.source ?? 'sparql-http.deleteByPattern', - }, 'deleteByPattern'); + }, 'deleteByPattern', [graphUri]); this.invalidateListGraphsCache(); if (graphUri) this.writeGen.recordGraphWrites([graphUri]); else this.writeGen.recordUnscopedWrite(); @@ -645,7 +893,7 @@ export class SparqlHttpStore implements TripleStore { await this.postUpdate(update, { ...options, source: options?.source ?? 'sparql-http.deleteBySubjectPrefix', - }, 'deleteBySubjectPrefix'); + }, 'deleteBySubjectPrefix', [graphUri]); this.invalidateListGraphsCache(); this.writeGen.recordGraphWrites([graphUri]); const after = await this.countQuads(graphUri, { @@ -661,6 +909,14 @@ export class SparqlHttpStore implements TripleStore { * so terms stay byte-identical (no JS round-trip). See {@link TripleStore.update}. */ async update(sparql: string, options?: UpdateOptions): Promise { + // `touchedGraphs` is only a cache hint and cannot prove the scope of an + // arbitrary SPARQL program. Until opaque writes rotate the materialization + // epoch, accepting one here could silently invalidate signed projections. + if (this.systemRecordAdmissionActive) { + throw new ManagedOxigraphMutationUnavailableError( + 'opaque SPARQL updates are unavailable while system-record admission is active', + ); + } await this.postUpdate(sparql, { ...options, source: options?.source ?? 'sparql-http.update', @@ -694,7 +950,7 @@ export class SparqlHttpStore implements TripleStore { }); const plan = buildAtomicGraphReplaceUpdate(graphUri, quads); const execute = async (update: string, source: string): Promise => { - await this.postUpdate(update, { ...options, source }, 'replaceGraph'); + await this.postUpdate(update, { ...options, source }, 'replaceGraph', [graphUri]); }; try { await execute(plan.update, options?.source ?? 'sparql-http.replaceGraph'); @@ -736,7 +992,12 @@ export class SparqlHttpStore implements TripleStore { metadataQuads, ); const execute = async (update: string, source: string): Promise => { - await this.postUpdate(update, { ...options, source }, 'replaceGraphAndSubject'); + await this.postUpdate( + update, + { ...options, source }, + 'replaceGraphAndSubject', + [graphUri, metaGraphUri], + ); }; try { await execute(plan.update, options?.source ?? 'sparql-http.replaceGraphAndSubject'); @@ -772,6 +1033,7 @@ export class SparqlHttpStore implements TripleStore { update, { ...options, source: options?.source ?? 'sparql-http.replaceSubject' }, 'replaceSubject', + [graphUri], ); } catch (error) { // Indeterminate remote failure: a timeout / lost response can occur AFTER @@ -884,7 +1146,7 @@ export class SparqlHttpStore implements TripleStore { await this.postUpdate(update, { ...options, source: options?.source ?? 'sparql-http.dropGraph', - }, 'dropGraph'); + }, 'dropGraph', [graphUri]); this.invalidateListGraphsCache(); this.writeGen.recordGraphWrites([graphUri]); } @@ -1008,7 +1270,23 @@ export class SparqlHttpStore implements TripleStore { // lifecycle owns one complete generation, aborting and draining every // operation admitted before close while rejecting work attempted during // close. A fresh generation is installed only after the drain completes. - await this.workLifecycle.close(new Error('SparqlHttpStore closed')); + try { + await this.workLifecycle.close(new Error('SparqlHttpStore closed')); + } finally { + // A capability probe registers the controller process-wide even when no + // session is ever opened. Do not strand that passive reservation after + // this store is gone; the helper refuses active/transitioning sessions. + if (this.systemRecordLaneOwner) { + if (await disposeSystemRecordLaneControllerV1(this.systemRecordLaneOwner)) { + // Clear the disposed object. Passive probes touch no child; an active + // controller has completed its one coalesced shutdown and released + // the process-global slot before this assignment. + this.systemRecordLane = undefined; + this.systemRecordLaneOwner = null; + this.systemRecordAdmissionActive = false; + } + } + } } } diff --git a/packages/storage/src/managed-oxigraph-ownership-v1-internal.ts b/packages/storage/src/managed-oxigraph-ownership-v1-internal.ts index 3ca3bd4571..ce678b72cb 100644 --- a/packages/storage/src/managed-oxigraph-ownership-v1-internal.ts +++ b/packages/storage/src/managed-oxigraph-ownership-v1-internal.ts @@ -67,6 +67,15 @@ const TERMINAL_REASONS: ReadonlySet = export interface ManagedOxigraphOwnershipSnapshotV1 { /** Canonical decimal u64, matching the system-record scalar codec. */ readonly childGeneration: string; + /** + * Exact supervisor-proven listener identities for this generation. + * + * Optional only for the B2-compatible diagnostic controller constructed + * without endpoints. Such a lease can report lifecycle state but can never + * satisfy the B3 endpoint-bound materialization capability. + */ + readonly queryEndpoint?: string; + readonly updateEndpoint?: string; /** True only while the supervisor-owned child is the proven ready listener. */ readonly ready: boolean; /** Once terminal, no generation can ever be bound again on this lease. */ @@ -76,6 +85,8 @@ export interface ManagedOxigraphOwnershipSnapshotV1 { interface LeaseState { generation: bigint; + readonly queryEndpoint?: string; + readonly updateEndpoint?: string; ready: boolean; terminal: boolean; lastInvalidation?: ManagedOxigraphOwnershipInvalidationV1; @@ -104,14 +115,110 @@ export interface ManagedOxigraphOwnershipControllerV1 { snapshot(): ManagedOxigraphOwnershipSnapshotV1; } -export function createManagedOxigraphOwnershipControllerV1(): ManagedOxigraphOwnershipControllerV1 { +export interface ManagedOxigraphEndpointIdentityV1 { + readonly queryEndpoint: string; + readonly updateEndpoint: string; +} + +const canonicalManagedEndpoint = ( + value: unknown, + path: '/query' | '/update', + label: string, +): string => { + if (typeof value !== 'string') { + throw new Error(`${label} must be a canonical loopback HTTP URL`); + } + const match = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})(\/query|\/update)$/.exec(value); + const portText = match?.[1]; + const port = portText === undefined ? 0 : Number(portText); + const canonical = `http://127.0.0.1:${portText ?? ''}${path}`; + if ( + !match || + match[2] !== path || + !Number.isSafeInteger(port) || + port < 1 || + port > 65_535 || + String(port) !== portText || + value !== canonical + ) { + throw new Error( + `${label} must be exactly http://127.0.0.1:${path} with no credentials, query, or fragment`, + ); + } + return canonical; +}; + +export function canonicalizeManagedOxigraphEndpointIdentityV1( + queryEndpoint: unknown, + updateEndpoint: unknown, +): ManagedOxigraphEndpointIdentityV1 { + const identity = Object.freeze({ + queryEndpoint: canonicalManagedEndpoint(queryEndpoint, '/query', 'managed Oxigraph query endpoint'), + updateEndpoint: canonicalManagedEndpoint(updateEndpoint, '/update', 'managed Oxigraph update endpoint'), + }); + if (new URL(identity.queryEndpoint).port !== new URL(identity.updateEndpoint).port) { + throw new Error('managed Oxigraph query and update endpoints must identify the same listener port'); + } + return identity; +} + +export function managedOxigraphOwnershipEndpointsMatchV1( + snapshot: ManagedOxigraphOwnershipSnapshotV1, + queryEndpoint: unknown, + updateEndpoint: unknown, +): boolean { + try { + const candidate = canonicalizeManagedOxigraphEndpointIdentityV1(queryEndpoint, updateEndpoint); + return candidate.queryEndpoint === snapshot.queryEndpoint && + candidate.updateEndpoint === snapshot.updateEndpoint; + } catch { + return false; + } +} + +const snapshotLeaseState = (current: LeaseState): ManagedOxigraphOwnershipSnapshotV1 => + Object.freeze({ + childGeneration: current.generation.toString(10), + ...(current.queryEndpoint === undefined + ? {} + : { + queryEndpoint: current.queryEndpoint, + updateEndpoint: current.updateEndpoint, + }), + ready: current.ready, + terminal: current.terminal, + ...(current.lastInvalidation === undefined + ? {} + : { lastInvalidation: current.lastInvalidation }), + }); + +export function createManagedOxigraphOwnershipControllerV1(): ManagedOxigraphOwnershipControllerV1; +export function createManagedOxigraphOwnershipControllerV1( + queryEndpoint: string, + updateEndpoint: string, +): ManagedOxigraphOwnershipControllerV1; +export function createManagedOxigraphOwnershipControllerV1( + queryEndpoint?: string, + updateEndpoint?: string, +): ManagedOxigraphOwnershipControllerV1 { + if ((queryEndpoint === undefined) !== (updateEndpoint === undefined)) { + throw new Error('managed Oxigraph ownership endpoints must be supplied together'); + } + const endpoints = queryEndpoint === undefined + ? undefined + : canonicalizeManagedOxigraphEndpointIdentityV1(queryEndpoint, updateEndpoint); // A bare object with no own properties: nothing to read, nothing to copy that // would carry meaning, and `JSON.stringify(lease)` is `"{}"`. const lease = Object.freeze( Object.create(null) as object, ) as ManagedOxigraphOwnershipLeaseV1; - LEASE_STATE.set(lease, { generation: 0n, ready: false, terminal: false }); + LEASE_STATE.set(lease, { + generation: 0n, + ...(endpoints === undefined ? {} : endpoints), + ready: false, + terminal: false, + }); const state = (): LeaseState => { const current = LEASE_STATE.get(lease); @@ -145,15 +252,7 @@ export function createManagedOxigraphOwnershipControllerV1(): ManagedOxigraphOwn }, snapshot(): ManagedOxigraphOwnershipSnapshotV1 { - const current = state(); - return Object.freeze({ - childGeneration: current.generation.toString(10), - ready: current.ready, - terminal: current.terminal, - ...(current.lastInvalidation === undefined - ? {} - : { lastInvalidation: current.lastInvalidation }), - }); + return snapshotLeaseState(state()); }, }); } @@ -168,8 +267,8 @@ export function createManagedOxigraphOwnershipControllerV1(): ManagedOxigraphOwn * advertise the lane at all rather than advertise one that can never open. */ export interface ManagedOxigraphSupervisorHandoffV1 { - stopAndProveOwnedChildDead(): Promise; - startAndProveCleanGeneration(): Promise; + stopAndProveOwnedChildDead(absoluteDeadlineMs?: number): Promise; + startAndProveCleanGeneration(absoluteDeadlineMs?: number): Promise; } const MANAGED_OXIGRAPH_HANDOFF_OPTION_KEY: unique symbol = Symbol( @@ -256,12 +355,5 @@ export function readManagedOxigraphOwnershipSnapshotV1( const current = LEASE_STATE.get(lease); /* c8 ignore next -- guarded by isManagedOxigraphOwnershipLeaseV1 above */ if (!current) return null; - return Object.freeze({ - childGeneration: current.generation.toString(10), - ready: current.ready, - terminal: current.terminal, - ...(current.lastInvalidation === undefined - ? {} - : { lastInvalidation: current.lastInvalidation }), - }); + return snapshotLeaseState(current); } diff --git a/packages/storage/src/system-record-apply-command-v1-internal.ts b/packages/storage/src/system-record-apply-command-v1-internal.ts new file mode 100644 index 0000000000..90854943d1 --- /dev/null +++ b/packages/storage/src/system-record-apply-command-v1-internal.ts @@ -0,0 +1,361 @@ +import { + assertSafeIri, + assertSafeRdfTerm, + isSafeIri, +} from '@origintrail-official/dkg-core'; +import { + SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + SYSTEM_RECORD_MAX_OWNED_SUBJECTS, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { Quad } from './triple-store.js'; +import { compareSystemRecordUtf8V1 } from './system-record-utf8-order-v1-internal.js'; +import { + SYSTEM_RECORD_V1_AUTHORITATIVE_AGENTS_GRAPH, + SYSTEM_RECORD_V1_PREDICATES, +} from './system-record-rdf-schema-v1-internal.js'; +import { + SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH, + SYSTEM_RECORD_V1_STATE_GRAPH, +} from './internal-graph-policy.js'; +import { + assertAuthenticSystemRecordActiveReplacementCompleteV1, + type SystemRecordActiveReplacementCompleteV1, +} from './system-record-next-state-v1-internal.js'; + +export interface SystemRecordConditionalApplyUpdateV1 { + readonly sparql: string; + readonly requestBytes: number; + readonly subjectUnion: readonly string[]; +} + +export type SystemRecordSparqlBuilderChargeV1 = (retainedBytes: number) => void; + +/** Linear UTF-8 merge of two already canonical subject tables. */ +export function mergeSystemRecordOwnedSubjectsV1( + previous: readonly string[], + next: readonly string[], +): readonly string[] { + const left = snapshotCanonicalSubjects(previous, 'previous owned-subject table'); + const right = snapshotCanonicalSubjects(next, 'next owned-subject table'); + const merged: string[] = []; + let leftIndex = 0; + let rightIndex = 0; + while (leftIndex < left.length || rightIndex < right.length) { + const leftValue = left[leftIndex]; + const rightValue = right[rightIndex]; + let selected: string; + if (leftValue === undefined) { + selected = rightValue; + rightIndex += 1; + } else if (rightValue === undefined) { + selected = leftValue; + leftIndex += 1; + } else { + const order = compareUtf8(leftValue, rightValue); + if (order < 0) { + selected = leftValue; + leftIndex += 1; + } else if (order > 0) { + selected = rightValue; + rightIndex += 1; + } else { + selected = leftValue; + leftIndex += 1; + rightIndex += 1; + } + } + if (merged.length >= SYSTEM_RECORD_MAX_OWNED_SUBJECTS) { + throw new Error('system-record prior/next subject union exceeds 2,048 subjects'); + } + merged.push(selected); + } + return Object.freeze(merged); +} + +/** Build one SPARQL Modify; a CAS miss gates both DELETE and INSERT. */ +export function buildSystemRecordConditionalApplyUpdateV1( + raw: SystemRecordActiveReplacementCompleteV1, + replaceBuilderCharge?: SystemRecordSparqlBuilderChargeV1, +): SystemRecordConditionalApplyUpdateV1 { + assertAuthenticSystemRecordActiveReplacementCompleteV1(raw); + const projectionGraph = assertProjectionGraph(raw.projectionGraph); + // The transition factory is the sole minter of this WeakSet-authentic, + // deeply frozen derivation. Re-copying its maximum 10,000-row projection here + // would create a second object graph inside the 12 MiB atomic lease without + // adding a trust boundary. + const priorSubjects = raw.priorSubjects; + const nextSubjects = raw.nextSubjects; + const subjectUnion = mergeSystemRecordOwnedSubjectsV1(priorSubjects, nextSubjects); + if (subjectUnion.length < 1) throw new Error('active materialization requires an owned subject'); + const oldReserved = raw.previousReservedQuads; + const nextReserved = raw.nextReservedQuads; + const absent = raw.requiredAbsentReservedSubjects; + const projection = raw.nextProjectionQuads; + const guards = raw.rootClaimGuards; + + const oldBySubject = groupBySubject(oldReserved); + for (const subject of absent) { + if (oldBySubject.has(subject)) { + throw new Error('one reserved subject cannot be expected present and absent'); + } + } + + const emit = (writer: SparqlWriterV1): void => { + writer.add('DELETE {\n'); + writer.add(' GRAPH '); + emitIri(writer, projectionGraph); + writer.add(' { ?deleteProjectionSubject ?deleteProjectionPredicate ?deleteProjectionObject . }\n'); + writer.add(' GRAPH '); + emitIri(writer, SYSTEM_RECORD_V1_STATE_GRAPH); + writer.add(' { ?deleteReservedSubject ?deleteReservedPredicate ?deleteReservedObject . }\n'); + writer.add('}\nINSERT {\n'); + writer.add(' GRAPH '); + emitIri(writer, projectionGraph); + writer.add(' { ?insertProjectionSubject ?insertProjectionPredicate ?insertProjectionObject . }\n'); + writer.add(' GRAPH '); + emitIri(writer, SYSTEM_RECORD_V1_STATE_GRAPH); + writer.add(' { ?insertReservedSubject ?insertReservedPredicate ?insertReservedObject . }\n'); + writer.add('}\nWHERE {\n'); + for (const quad of oldReserved) emitGraphQuad(writer, quad, ' '); + for (const [subject, quads] of oldBySubject) { + writer.add(' FILTER NOT EXISTS { GRAPH '); + emitIri(writer, SYSTEM_RECORD_V1_STATE_GRAPH); + writer.add(' { '); + emitIri(writer, subject); + writer.add(' ?unexpectedPredicate ?unexpectedObject . FILTER(!('); + for (const [index, quad] of quads.entries()) { + if (index > 0) writer.add(' || '); + writer.add('(?unexpectedPredicate = '); + emitIri(writer, quad.predicate); + writer.add(' && sameTerm(?unexpectedObject, '); + emitObject(writer, quad.object); + writer.add('))'); + } + writer.add(')) } }\n'); + } + for (const subject of absent) { + writer.add(' FILTER NOT EXISTS { GRAPH '); + emitIri(writer, SYSTEM_RECORD_V1_STATE_GRAPH); + writer.add(' { '); + emitIri(writer, subject); + writer.add(' ?absentPredicate ?absentObject . } }\n'); + } + for (const guard of guards) { + writer.add(' FILTER NOT EXISTS { GRAPH '); + emitIri(writer, SYSTEM_RECORD_V1_STATE_GRAPH); + writer.add(' { '); + emitIri(writer, guard.claimSubject); + writer.add(' '); + emitIri(writer, SYSTEM_RECORD_V1_PREDICATES.claimedBy); + writer.add(' ?rootOwner . FILTER(?rootOwner != '); + emitIri(writer, guard.recordSubject); + writer.add(') } }\n'); + } + // Four disjoint branches prevent a cross product between prior rows and inserts. + writer.add(' {\n'); + writer.add(' VALUES ?deleteProjectionSubject {'); + for (const subject of subjectUnion) { + writer.add(' '); + emitIri(writer, subject); + } + writer.add(' }\n'); + writer.add(' GRAPH '); + emitIri(writer, projectionGraph); + writer.add(' { ?deleteProjectionSubject ?deleteProjectionPredicate ?deleteProjectionObject . }\n'); + writer.add(' }\n'); + if (oldReserved.length > 0) { + writer.add(' UNION {\n'); + writer.add(' VALUES (?deleteReservedSubject ?deleteReservedPredicate ?deleteReservedObject) {\n'); + for (const quad of oldReserved) emitQuadTuple(writer, quad, ' '); + writer.add(' }\n'); + writer.add(' GRAPH '); + emitIri(writer, SYSTEM_RECORD_V1_STATE_GRAPH); + writer.add(' { ?deleteReservedSubject ?deleteReservedPredicate ?deleteReservedObject . }\n'); + writer.add(' }\n'); + } + if (projection.length > 0) { + writer.add(' UNION {\n'); + writer.add(' VALUES (?insertProjectionSubject ?insertProjectionPredicate ?insertProjectionObject) {\n'); + for (const quad of projection) emitQuadTuple(writer, quad, ' '); + writer.add(' }\n'); + writer.add(' }\n'); + } + if (nextReserved.length > 0) { + writer.add(' UNION {\n'); + writer.add(' VALUES (?insertReservedSubject ?insertReservedPredicate ?insertReservedObject) {\n'); + for (const quad of nextReserved) emitQuadTuple(writer, quad, ' '); + writer.add(' }\n'); + writer.add(' }\n'); + } + writer.add('}\n'); + }; + + const counter = new CountingSparqlWriter(); + emit(counter); + const writer = new BoundedSparqlWriter(counter.bytes, replaceBuilderCharge); + emit(writer); + const sparql = writer.finish(); + return Object.freeze({ sparql, requestBytes: counter.bytes, subjectUnion }); +} + +interface SparqlWriterV1 { + add(value: string): void; +} + +class CountingSparqlWriter implements SparqlWriterV1 { + private encodedBytes = 0; + + get bytes(): number { return this.encodedBytes; } + + add(value: string): void { + const bytes = Buffer.byteLength(value, 'utf8'); + if (bytes > SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES - this.encodedBytes) { + throw new Error('system-record SPARQL request exceeds the 4 MiB bound'); + } + this.encodedBytes += bytes; + } +} + +class BoundedSparqlWriter { + private buffer: Buffer | null; + private offset = 0; + + constructor( + private readonly encodedBytes: number, + private readonly replaceCharge?: SystemRecordSparqlBuilderChargeV1, + ) { + // One exact encoded buffer and the final two-byte-weighted JS string coexist + // during conversion. There is no fragments array or join-time second string. + const peakBytes = encodedBytes * 3; + if (peakBytes > SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES) { + throw new Error('system-record SPARQL builder exceeds the retained-byte bound'); + } + replaceCharge?.(peakBytes); + this.buffer = Buffer.allocUnsafe(encodedBytes); + } + + add(value: string): void { + const bytes = Buffer.byteLength(value, 'utf8'); + if (this.buffer === null || bytes > this.encodedBytes - this.offset) { + throw new Error('system-record SPARQL request accounting mismatch'); + } + const written = this.buffer.write(value, this.offset, bytes, 'utf8'); + if (written !== bytes) throw new Error('system-record SPARQL request accounting mismatch'); + this.offset += written; + } + + finish(): string { + if (this.buffer === null || this.offset !== this.encodedBytes) { + throw new Error('system-record SPARQL request accounting mismatch'); + } + const result = this.buffer.toString('utf8'); + this.buffer = null; + this.replaceCharge?.(this.encodedBytes * 2); + if (Buffer.byteLength(result, 'utf8') !== this.encodedBytes) { + throw new Error('system-record SPARQL request accounting mismatch'); + } + return result; + } +} + +function snapshotCanonicalSubjects(value: unknown, label: string): readonly string[] { + const values = closedArray(value, SYSTEM_RECORD_MAX_OWNED_SUBJECTS, label); + const result = values.map((candidate) => { + if (typeof candidate !== 'string' || !isSafeIri(candidate)) { + throw new Error(`${label} contains an unsafe IRI`); + } + return candidate; + }); + for (let index = 1; index < result.length; index += 1) { + if (compareUtf8(result[index - 1], result[index]) >= 0) { + throw new Error(`${label} must be UTF-8 sorted and duplicate-free`); + } + } + return Object.freeze(result); +} + +function groupBySubject(quads: readonly Readonly[]): Map[]> { + const mutable = new Map[]>(); + for (const quad of quads) { + const rows = mutable.get(quad.subject) ?? []; + rows.push(quad); + mutable.set(quad.subject, rows); + } + return new Map([...mutable].map(([subject, rows]) => [subject, Object.freeze(rows)])); +} + +function assertProjectionGraph(value: string): string { + if (value !== SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH + && value !== SYSTEM_RECORD_V1_AUTHORITATIVE_AGENTS_GRAPH) { + throw new Error('system-record projection graph is not the fixed agents graph'); + } + return value; +} + +function emitIri(writer: SparqlWriterV1, value: string): void { + assertSafeIri(value); + writer.add('<'); + writer.add(value); + writer.add('>'); +} + +function emitObject(writer: SparqlWriterV1, value: string): void { + if (value.startsWith('"')) { + assertSafeRdfTerm(value); + writer.add(value); + return; + } + emitIri(writer, value); +} + +function emitGraphQuad(writer: SparqlWriterV1, quad: Readonly, indent: string): void { + writer.add(indent); + writer.add('GRAPH '); + emitIri(writer, quad.graph); + writer.add(' { '); + emitIri(writer, quad.subject); + writer.add(' '); + emitIri(writer, quad.predicate); + writer.add(' '); + emitObject(writer, quad.object); + writer.add(' . }\n'); +} + +function emitQuadTuple(writer: SparqlWriterV1, quad: Readonly, indent: string): void { + writer.add(indent); + writer.add('('); + emitIri(writer, quad.subject); + writer.add(' '); + emitIri(writer, quad.predicate); + writer.add(' '); + emitObject(writer, quad.object); + writer.add(')\n'); +} + +function closedArray(value: unknown, maxLength: number, label: string): unknown[] { + if (!Array.isArray(value) || !Number.isSafeInteger(value.length) || value.length > maxLength) { + throw new Error(`${label} must be a bounded array`); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1) throw new Error(`${label} must be a closed dense array`); + const result = new Array(value.length); + for (const key of keys) { + if (key === 'length') continue; + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) { + throw new Error(`${label} contains a non-index property`); + } + const index = Number(key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!Number.isSafeInteger(index) || index >= value.length || !descriptor?.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} must contain enumerable data elements`); + } + result[index] = descriptor.value; + } + return result; +} + +function compareUtf8(left: string, right: string): number { + return compareSystemRecordUtf8V1(left, right); +} diff --git a/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts b/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts new file mode 100644 index 0000000000..bc6d797474 --- /dev/null +++ b/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts @@ -0,0 +1,1134 @@ +import { createHash } from 'node:crypto'; + +import { + KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1, + tripleContentV10, +} from '@origintrail-official/dkg-core'; +import { + computeSystemRecordStableKeyHashV1, + SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, + SYSTEM_RECORD_APPLY_TIMEOUT_MS, + SYSTEM_RECORD_INSPECTION_TIMEOUT_MS, + SYSTEM_RECORD_MAX_ATOMIC_INSPECTION_RESPONSE_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + SYSTEM_RECORD_MAX_PROJECTION_BYTES, + SYSTEM_RECORD_REQUIRED_DISPATCH_BUDGET_MS, + type Digest32V1, + type NetworkIdV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import type { ManagedHttpBodyLimits, ManagedHttpResponse } from './adapters/managed-http-client.js'; +import { + SPARQL_QUERY_CONTENT_TYPE, + SPARQL_UPDATE_CONTENT_TYPE, +} from './adapters/sparql-content-types.js'; +import { + buildSystemRecordConditionalApplyUpdateV1, +} from './system-record-apply-command-v1-internal.js'; +import { + buildSystemRecordProjectionInspectionQueryV1, + buildSystemRecordReservedInspectionQueryV1, + estimateSystemRecordInspectionParseBytesV1, + parseSystemRecordInspectionResponseV1, + retainedSystemRecordInspectionQuadsBytesV1, + SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1, + SYSTEM_RECORD_MAX_RESERVED_INSPECTION_RESPONSE_BYTES_V1, + SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1, +} from './system-record-inspection-v1-internal.js'; +import { + systemRecordCapacitySubjectV1, + systemRecordEpochSubjectV1, + systemRecordReceiptSubjectV1, + systemRecordRecordSubjectV1, + systemRecordRootClaimSubjectV1, + systemRecordProjectionGraphV1, +} from './system-record-rdf-schema-v1-internal.js'; +import { + assertSystemRecordRootClaimSnapshotV1, + decodeSystemRecordAppliedSnapshotV1, + type SystemRecordAppliedSnapshotV1, +} from './system-record-state-snapshot-v1-internal.js'; +import { + assertAuthenticSystemRecordActiveReplacementCompleteV1, + deriveSystemRecordActiveReplacementV1, + type SystemRecordActiveReplacementCompleteV1, + type SystemRecordActiveReplacementReadyV1, +} from './system-record-next-state-v1-internal.js'; +import type { + SystemRecordApplyOutcomeV1, + SystemRecordLaneExecutionBindingV1, +} from './system-record-materializer-v1.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from './internal-graph-policy.js'; +import { externalStorePriorityScheduler } from './store-priority-scheduler.js'; +import type { Quad } from './triple-store.js'; +import type { + SystemRecordAtomicChargeCategoryV1, + SystemRecordVerifiedReplacementConsumerV1, + SystemRecordVerifiedReplacementFactsV1, +} from './system-record-verified-replacement-v1-internal.js'; +import { compareSystemRecordUtf8V1 } from './system-record-utf8-order-v1-internal.js'; + +const UPDATE_RESPONSE_BYTES_V1 = 8 * 1024; + +/** The minimal generation-owned transport surface consumed by this executor. */ +export interface SystemRecordAtomicApplyHttpClientV1 { + readonly childGeneration: string; + readonly isDestroyed: boolean; + /** Internal builder hook; absent on non-accounted test transports. */ + readonly replaceRequestRetainedBytes?: (bytes: number) => void; + post( + url: string, + contentType: string, + body: string, + timeoutMs: number, + signal?: AbortSignal, + limits?: ManagedHttpBodyLimits, + ): Promise; +} + +export interface SystemRecordAtomicRecoveryRuntimeV1 { + readonly client: SystemRecordAtomicApplyHttpClientV1; + readonly queryEndpoint: string; + readonly absoluteDeadlineMs: number; + /** Lifecycle-owned cancellation; shutdown aborts and joins the exact read. */ + readonly signal: AbortSignal; + /** Rechecks the supervisor lease and exact endpoint/generation binding. */ + readonly assertAttributable: () => boolean; +} + +export type SystemRecordAtomicRecoveryResolutionV1 = + | { readonly resolution: 'applied'; readonly stateRevision: string; readonly appliedStateDigest: string } + | { readonly resolution: 'not-applied' } + | { readonly resolution: 'unavailable' }; + +export interface SystemRecordAtomicRecoveryRequestV1 { + readonly ownership: object; + readonly binding: SystemRecordLaneExecutionBindingV1; + readonly reconcile: ( + runtime: SystemRecordAtomicRecoveryRuntimeV1, + ) => Promise; +} + +export interface SystemRecordAtomicRecoveryRegistrationV1 { + readonly ownership: object; + readonly recoveryGeneration: string; + readonly completion: Promise; +} + +export type SystemRecordAtomicRecoveryRegistrarV1 = ( + request: SystemRecordAtomicRecoveryRequestV1, +) => SystemRecordAtomicRecoveryRegistrationV1; + +export type SystemRecordAtomicApplySettlementV1 = + | { + readonly settlement: 'no-mutation'; + readonly outcome: Exclude; + } + | { + readonly settlement: 'settled'; + readonly outcome: Extract; + } + | { + readonly settlement: 'recovery-owned'; + readonly outcome: Extract; + readonly recovery: SystemRecordAtomicRecoveryRegistrationV1; + }; + +interface SystemRecordAtomicApplySchedulerV1 { + run( + priority: 'normal', + operation: string, + work: () => Promise, + signal: AbortSignal | undefined, + admission: { + readonly storeId: object; + readonly generation: string; + readonly domain: 'agents'; + readonly mode: 'exclusive'; + }, + ): Promise; +} + +export interface SystemRecordAtomicApplyExecutorDepsV1 { + readonly consumer: SystemRecordVerifiedReplacementConsumerV1; + readonly storeId: object; + readonly queryEndpoint: string; + readonly updateEndpoint: string; + /** Must return only a live client bound to the supplied execution binding. */ + readonly resolveClient: ( + binding: SystemRecordLaneExecutionBindingV1, + ) => SystemRecordAtomicApplyHttpClientV1 | null; + /** + * Must synchronously enqueue the existing coalesced control barrier before + * returning. The executor calls it while its exclusive permit is still live. + */ + readonly now?: () => number; + readonly scheduler?: SystemRecordAtomicApplySchedulerV1; +} + +export interface SystemRecordAtomicApplyExecutorV1 { + /** Release one authentic proof refused by lifecycle before executor admission. */ + discard(proof: unknown): void; + execute( + proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + registerRecovery: SystemRecordAtomicRecoveryRegistrarV1, + ): Promise; +} + +interface ExactMaterializationV1 { + readonly reservedSubjects: readonly string[]; + readonly reservedQuads: readonly Readonly[]; + readonly projectionSubjects: readonly string[]; + readonly projectionQuads: readonly Readonly[]; + readonly mode: 'shadow' | 'authoritative'; +} + +export function createSystemRecordAtomicApplyExecutorV1( + deps: SystemRecordAtomicApplyExecutorDepsV1, +): SystemRecordAtomicApplyExecutorV1 { + const now = deps.now ?? (() => performance.now()); + const scheduler = deps.scheduler ?? externalStorePriorityScheduler; + + return Object.freeze({ + discard: (proof: unknown): void => deps.consumer.discardProof(proof), + execute: async ( + proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + registerRecovery: SystemRecordAtomicRecoveryRegistrarV1, + ) => { + let admittedDeadlineMs: number; + try { + admittedDeadlineMs = deps.consumer.inspectDeadline(proof, Object.freeze({ + ...binding, + networkId: binding.networkId as NetworkIdV1, + })); + } catch { + discardUnconsumedProof(deps.consumer, proof); + return noMutation({ outcome: 'capability-lost' }); + } + const deadline = deadlineSignal(admittedDeadlineMs, now); + if (deadline.signal.aborted) { + discardUnconsumedProof(deps.consumer, proof); + return noMutation({ outcome: 'deferred', reason: 'aborted' }); + } + + let started = false; + try { + return await scheduler.run( + 'normal', + 'system-record.apply-v1', + () => { + started = true; + return executeAdmitted(deps, proof, binding, registerRecovery, now); + }, + deadline.signal, + Object.freeze({ + storeId: deps.storeId, + generation: binding.childGeneration, + domain: 'agents', + mode: 'exclusive', + }), + ); + } catch (error) { + if (!started) { + discardUnconsumedProof(deps.consumer, proof); + if (deadline.signal.aborted) { + return noMutation({ outcome: 'deferred', reason: 'aborted' }); + } + } + throw error; + } finally { + deadline.cancel(); + } + }, + }); +} + +async function executeAdmitted( + deps: SystemRecordAtomicApplyExecutorDepsV1, + proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + registerRecovery: SystemRecordAtomicRecoveryRegistrarV1, + now: () => number, +): Promise { + let facts: SystemRecordVerifiedReplacementFactsV1; + try { + // Consumption happens only after exclusive admission. Queue wait therefore + // consumes the issuer-minted absolute budget without burning the proof. + facts = deps.consumer.consume(proof, Object.freeze({ + ...binding, + networkId: binding.networkId as NetworkIdV1, + })); + } catch { + discardUnconsumedProof(deps.consumer, proof); + return noMutation({ outcome: 'capability-lost' }); + } + let recoveryOwnsReservation = false; + try { + const generationClient = deps.resolveClient(binding); + if (generationClient === null || generationClient.isDestroyed + || generationClient.childGeneration !== binding.childGeneration) { + return noMutation({ outcome: 'capability-lost' }); + } + const client = accountedClient(generationClient, deps.consumer, facts); + const inspectionDeadlineMs = Math.min( + facts.admittedDeadlineMs, + now() + SYSTEM_RECORD_INSPECTION_TIMEOUT_MS, + ); + + const stableKeyHash = computeSystemRecordStableKeyHashV1( + facts.networkId, + facts.head.peerId, + ); + let snapshot: SystemRecordAppliedSnapshotV1; + let rootClaimQuads: readonly Readonly[]; + try { + let retainedInitialBytes = 0; + const initialSubjects = fixedInitialSubjects(facts.networkId, stableKeyHash); + const initialQuads = await readReserved( + client, + deps.queryEndpoint, + initialSubjects, + boundedInspectionTimeout(inspectionDeadlineMs, now), + undefined, + (quads) => { + retainedInitialBytes = retainedSystemRecordInspectionQuadsBytesV1(quads); + replacePreparedCharge(deps.consumer, facts, retainedInitialBytes); + }, + ); + snapshot = decodeSystemRecordAppliedSnapshotV1({ + networkId: facts.networkId, + stableKeyHash, + materializationEpoch: facts.materializationEpoch, + quads: initialQuads, + }); + const rootSubjects = rootClaimSubjects(facts, snapshot); + rootClaimQuads = await readReserved( + client, + deps.queryEndpoint, + rootSubjects, + boundedInspectionTimeout(inspectionDeadlineMs, now), + undefined, + (quads) => replacePreparedCharge( + deps.consumer, + facts, + retainedInitialBytes + retainedSystemRecordInspectionQuadsBytesV1(quads), + ), + ); + } catch (error) { + return noMutation({ outcome: 'deferred', reason: classifyInspectionFailure(error) }); + } + + let prepared: ReturnType; + try { + prepared = deriveSystemRecordActiveReplacementV1(Object.freeze({ + facts, + snapshot, + observedRootClaimQuads: rootClaimQuads, + })); + } catch { + return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); + } + if (prepared.outcome !== 'ready' && prepared.outcome !== 'already-applied') { + return noMutation(mapZeroWrite(prepared)); + } + assertAuthenticSystemRecordActiveReplacementCompleteV1(prepared); + + let update: ReturnType; + try { + // The writer accepts only this factory-authentic complete derivation. No + // caller can omit one expected state/claim/capacity/receipt CAS member. + update = buildSystemRecordConditionalApplyUpdateV1( + prepared, + (bytes) => replacePreparedCharge(deps.consumer, facts, bytes), + ); + assertCompletePriorBinding(prepared, snapshot, rootClaimQuads); + } catch { + return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); + } + const exactNext = exactMaterialization(prepared, update.subjectUnion, binding.mode); + try { + replacePreparedCharge( + deps.consumer, + facts, + retainedPreparationBytes(update.sparql, exactNext), + ); + } catch { + return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); + } + + let exactPrior: ExactMaterializationV1; + try { + const priorReservedSubjects = relevantReservedSubjects(prepared); + const priorReservedQuads = canonicalQuads([ + ...snapshot.previousReservedQuads, + ...rootClaimQuads, + ]); + exactPrior = Object.freeze({ + reservedSubjects: priorReservedSubjects, + reservedQuads: priorReservedQuads, + projectionSubjects: update.subjectUnion, + projectionQuads: await readProjection( + client, + deps.queryEndpoint, + binding.mode, + update.subjectUnion, + boundedInspectionTimeout(inspectionDeadlineMs, now), + undefined, + (quads) => replacePreparedCharge( + deps.consumer, + facts, + retainedPreparationBytes(update.sparql, exactNext, Object.freeze({ + reservedSubjects: priorReservedSubjects, + reservedQuads: priorReservedQuads, + projectionSubjects: update.subjectUnion, + projectionQuads: quads, + mode: binding.mode, + })), + ), + ), + mode: binding.mode, + }); + replacePreparedCharge( + deps.consumer, + facts, + retainedPreparationBytes(update.sparql, exactNext, exactPrior), + ); + } catch (error) { + return noMutation({ outcome: 'deferred', reason: classifyInspectionFailure(error) }); + } + + try { + if (!matchesProjectionSnapshot(snapshot, exactPrior.projectionQuads)) { + return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); + } + } catch { + return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); + } + + if (matchesExact(exactPrior, exactNext)) { + return noMutation({ + outcome: 'already-applied', + stateRevision: prepared.success.stateRevision, + appliedStateDigest: prepared.success.appliedStateDigest, + }); + } + if (prepared.outcome === 'already-applied') { + return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); + } + if (facts.admittedDeadlineMs - now() < SYSTEM_RECORD_REQUIRED_DISPATCH_BUDGET_MS) { + return noMutation({ outcome: 'deferred', reason: 'insufficient-apply-budget' }); + } + if (deps.resolveClient(binding) !== generationClient || client.isDestroyed + || client.childGeneration !== binding.childGeneration) { + return noMutation({ outcome: 'deferred', reason: 'generation-changed' }); + } + const updateTimeout = boundedApplyTimeout(facts.admittedDeadlineMs, now); + if (updateTimeout === null) { + return noMutation({ outcome: 'deferred', reason: 'insufficient-apply-budget' }); + } + + const retainedBeforePostRead = retainedPreparationBytes( + update.sparql, + exactNext, + exactPrior, + ); + let updateFailure: unknown = null; + try { + const response = await client.post( + deps.updateEndpoint, + SPARQL_UPDATE_CONTENT_TYPE, + update.sparql, + updateTimeout, + undefined, + { + maxRequestBytes: SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + maxResponseBytes: UPDATE_RESPONSE_BYTES_V1, + }, + ); + if (response.status < 200 || response.status >= 300) { + updateFailure = new Error(`system-record apply failed with HTTP ${response.status}`); + } + } catch (error) { + updateFailure = error; + } + + if (deps.resolveClient(binding) !== generationClient || client.isDestroyed + || client.childGeneration !== binding.childGeneration) { + updateFailure ??= new Error('managed child generation changed after apply dispatch'); + } else { + try { + const observed = await readExactMaterialization( + client, + deps.queryEndpoint, + exactNext, + facts.admittedDeadlineMs, + now, + undefined, + (observedBytes) => replacePreparedCharge( + deps.consumer, + facts, + retainedBeforePostRead + observedBytes, + ), + ); + if (deps.resolveClient(binding) !== generationClient || client.isDestroyed + || client.childGeneration !== binding.childGeneration) { + updateFailure ??= new Error('managed child generation changed during final post-read'); + } else if (matchesExact(observed, exactNext)) { + return Object.freeze({ + settlement: 'settled', + outcome: Object.freeze({ + outcome: 'applied', + stateRevision: prepared.success.stateRevision, + appliedStateDigest: prepared.success.appliedStateDigest, + }), + }); + } else if (matchesExact(observed, exactPrior)) { + return noMutation({ outcome: 'deferred', reason: 'state-changed' }); + } else { + updateFailure ??= new Error('system-record post-read matched neither prior nor next state'); + } + } catch (error) { + updateFailure ??= error; + } + } + + const settlement = transferToRecovery( + binding, + prepared, + exactPrior, + exactNext, + retainedBeforePostRead, + updateFailure, + registerRecovery, + deps.consumer, + facts, + now, + ); + recoveryOwnsReservation = true; + return settlement; + } finally { + if (!recoveryOwnsReservation) deps.consumer.release(facts); + } +} + +function discardUnconsumedProof( + consumer: SystemRecordVerifiedReplacementConsumerV1, + proof: unknown, +): void { + try { + consumer.discardProof(proof); + } catch { + // Invalid, foreign, released, and already-consumed handles own nothing that + // this pre-consumption exit may release. + } +} + +function transferToRecovery( + binding: SystemRecordLaneExecutionBindingV1, + prepared: SystemRecordActiveReplacementReadyV1, + exactPrior: ExactMaterializationV1, + exactNext: ExactMaterializationV1, + retainedBeforePostRead: number, + cause: unknown, + registerRecovery: SystemRecordAtomicRecoveryRegistrarV1, + consumer: SystemRecordVerifiedReplacementConsumerV1, + facts: SystemRecordVerifiedReplacementFactsV1, + now: () => number, +): SystemRecordAtomicApplySettlementV1 { + const ownership = Object.freeze(Object.create(null) as object); + const request: SystemRecordAtomicRecoveryRequestV1 = Object.freeze({ + ownership, + binding, + reconcile: async ( + runtime: SystemRecordAtomicRecoveryRuntimeV1, + ): Promise => { + if (runtime.signal.aborted || !runtime.assertAttributable() || runtime.client.isDestroyed) { + return Object.freeze({ resolution: 'unavailable' as const }); + } + try { + const observed = await readExactMaterialization( + accountedClient(runtime.client, consumer, facts), + runtime.queryEndpoint, + exactNext, + runtime.absoluteDeadlineMs, + now, + runtime.signal, + (observedBytes) => replacePreparedCharge( + consumer, + facts, + retainedBeforePostRead + observedBytes, + ), + ); + if (runtime.signal.aborted || !runtime.assertAttributable()) { + return Object.freeze({ resolution: 'unavailable' as const }); + } + if (matchesExact(observed, exactNext)) { + return Object.freeze({ + resolution: 'applied' as const, + stateRevision: prepared.success.stateRevision, + appliedStateDigest: prepared.success.appliedStateDigest, + }); + } + if (matchesExact(observed, exactPrior)) { + return Object.freeze({ resolution: 'not-applied' as const }); + } + } catch { + // Recovery owns the retained state and decides terminal availability. + } + return Object.freeze({ resolution: 'unavailable' as const }); + }, + }); + + let registration: SystemRecordAtomicRecoveryRegistrationV1; + try { + // This call is intentionally synchronous and happens before the scheduler + // callback resolves. A compliant implementation has already enqueued the + // control barrier and sealed the store when it returns. + registration = registerRecovery(request); + } catch (error) { + throw new Error( + 'system-record uncertain apply could not transfer authoritative ownership to recovery', + { cause: error ?? cause }, + ); + } + if (registration.ownership !== ownership + || typeof registration.recoveryGeneration !== 'string' + || !/^(0|[1-9][0-9]*)$/.test(registration.recoveryGeneration) + || !(registration.completion instanceof Promise)) { + throw new Error('system-record recovery registration did not accept the exact ownership token', { + cause, + }); + } + consumer.transferToRecovery(facts, ownership, registration.completion); + const recovery = Object.freeze({ ...registration }); + return Object.freeze({ + settlement: 'recovery-owned', + outcome: Object.freeze({ + outcome: 'indeterminate', + recoveryGeneration: recovery.recoveryGeneration, + }), + recovery, + }); +} + +function accountedClient( + client: SystemRecordAtomicApplyHttpClientV1, + consumer: SystemRecordVerifiedReplacementConsumerV1, + facts: SystemRecordVerifiedReplacementFactsV1, +): SystemRecordAtomicApplyHttpClientV1 { + const replace = (category: SystemRecordAtomicChargeCategoryV1, bytes: number) => { + consumer.replaceCharge(facts, category, bytes); + }; + return Object.freeze({ + childGeneration: client.childGeneration, + get isDestroyed() { + return client.isDestroyed; + }, + replaceRequestRetainedBytes: (bytes: number) => replace('request', bytes), + async post( + url: string, + contentType: string, + body: string, + timeoutMs: number, + signal?: AbortSignal, + limits?: ManagedHttpBodyLimits, + ): Promise { + const preparedRequest = contentType.startsWith(SPARQL_UPDATE_CONTENT_TYPE); + const requestBytes = Buffer.byteLength(body, 'utf8'); + // Query strings are ephemeral and coexist with the encoded HTTP payload; + // the prepared update string is already charged in `prepared`, so only + // its encoded payload belongs here. + replace('request', preparedRequest ? requestBytes : requestBytes * 3); + try { + const boundedLimits = limits === undefined ? undefined : Object.freeze({ + ...limits, + reserveResponseCapacity: (capacityBytes: number) => { + limits.reserveResponseCapacity?.(capacityBytes); + // Exact encoded capacity and the conservative two-byte JS result + // coexist during Buffer.toString(). Refuse before allocation when + // that physical peak cannot fit the aggregate lease. + replace('response', capacityBytes * 3); + }, + }); + let response: ManagedHttpResponse; + try { + response = await client.post( + url, + contentType, + body, + timeoutMs, + signal, + boundedLimits, + ); + } catch (error) { + replace('response', 0); + throw error; + } + // The managed client has released its bounded encoded response by this + // point; retain the conservative two-byte JS string weight until the + // parser replaces it on the next phase. + replace('response', Buffer.byteLength(response.body, 'utf8') * 2); + return Object.freeze({ + ...response, + replaceRetainedBytes: (bytes: number) => replace('response', bytes), + }); + } finally { + replace('request', 0); + } + }, + }); +} + +function retainedPreparationBytes( + sparql: string, + ...materializations: readonly ExactMaterializationV1[] +): number { + let bytes = 2 * Buffer.byteLength(sparql, 'utf8'); + for (const [materializationIndex, materialization] of materializations.entries()) { + const sharesVerifiedProjectionTerms = materializationIndex === 0; + for (const subject of [ + ...materialization.reservedSubjects, + ...materialization.projectionSubjects, + ]) { + bytes += 128 + (sharesVerifiedProjectionTerms + ? 0 + : 2 * Buffer.byteLength(subject, 'utf8')); + } + for (const quad of materialization.reservedQuads) { + bytes += 2 * (Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + Buffer.byteLength(quad.graph, 'utf8')) + + 128; + } + for (const quad of materialization.projectionQuads) { + // exactNext is a graph-tagged object view over strings already owned and + // charged by the verified facts. exactPrior comes from a fresh endpoint + // decode and therefore owns its terms too. + bytes += 128 + (sharesVerifiedProjectionTerms ? 0 : 2 * ( + Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + Buffer.byteLength(quad.graph, 'utf8') + )); + } + if (!Number.isSafeInteger(bytes)) return Number.MAX_SAFE_INTEGER; + } + return bytes; +} + +function replacePreparedCharge( + consumer: SystemRecordVerifiedReplacementConsumerV1, + facts: SystemRecordVerifiedReplacementFactsV1, + bytes: number, +): void { + if (!Number.isSafeInteger(bytes) || bytes < 0 + || bytes > SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES) { + throw new Error('system-record retained preparation exceeds its 8 MiB storage-local bound'); + } + consumer.replaceCharge(facts, 'prepared', bytes); +} + +function deadlineSignal( + admittedDeadlineMs: number, + now: () => number, +): Readonly<{ signal: AbortSignal; cancel: () => void }> { + const controller = new AbortController(); + const remainingMs = admittedDeadlineMs - now(); + let timer: ReturnType | undefined; + if (remainingMs <= 0) { + controller.abort(new Error('system-record admission deadline elapsed')); + } else { + timer = setTimeout( + () => controller.abort(new Error('system-record admission deadline elapsed')), + Math.max(1, Math.ceil(remainingMs)), + ); + timer.unref?.(); + } + return Object.freeze({ + signal: controller.signal, + cancel: () => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + }, + }); +} + +async function readExactMaterialization( + client: SystemRecordAtomicApplyHttpClientV1, + queryEndpoint: string, + shape: ExactMaterializationV1, + deadlineMs: number, + now: () => number, + signal?: AbortSignal, + chargeObserved?: (bytes: number) => void, +): Promise { + const reserved = await readReserved( + client, + queryEndpoint, + shape.reservedSubjects, + boundedInspectionTimeout(deadlineMs, now), + signal, + (quads) => chargeObserved?.(retainedObservedMaterializationBytes(shape, quads, [])), + ); + const projection = await readProjection( + client, + queryEndpoint, + shape.mode, + shape.projectionSubjects, + boundedInspectionTimeout(deadlineMs, now), + signal, + (quads) => chargeObserved?.(retainedObservedMaterializationBytes(shape, reserved, quads)), + ); + const observed = Object.freeze({ + ...shape, + reservedQuads: reserved, + projectionQuads: projection, + }); + chargeObserved?.(retainedObservedMaterializationBytes(shape, reserved, projection)); + return observed; +} + +async function readReserved( + client: SystemRecordAtomicApplyHttpClientV1, + endpoint: string, + subjects: readonly string[], + timeoutMs: number, + signal?: AbortSignal, + retainParsed?: (quads: readonly Readonly[]) => void, +): Promise[]> { + let query: string | null = null; + let response: ManagedHttpResponse; + try { + query = buildSystemRecordReservedInspectionQueryV1( + subjects, + client.replaceRequestRetainedBytes, + ); + const pending = client.post( + endpoint, + SPARQL_QUERY_CONTENT_TYPE, + query, + timeoutMs, + signal, + { + maxRequestBytes: SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + maxResponseBytes: SYSTEM_RECORD_MAX_RESERVED_INSPECTION_RESPONSE_BYTES_V1, + }, + ); + query = null; + response = await pending; + } catch (error) { + client.replaceRequestRetainedBytes?.(0); + throw error; + } + if (response.status < 200 || response.status >= 300) { + response.replaceRetainedBytes?.(0); + throw new Error(`system-record reserved inspection failed with HTTP ${response.status}`); + } + return parseAccountedInspection(response, { + scope: 'reserved', + allowedSubjects: subjects, + maxRows: SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1, + }, retainParsed); +} + +async function readProjection( + client: SystemRecordAtomicApplyHttpClientV1, + endpoint: string, + mode: 'shadow' | 'authoritative', + subjects: readonly string[], + timeoutMs: number, + signal?: AbortSignal, + retainParsed?: (quads: readonly Readonly[]) => void, +): Promise[]> { + let query: string | null = null; + let response: ManagedHttpResponse; + try { + query = buildSystemRecordProjectionInspectionQueryV1( + mode, + subjects, + client.replaceRequestRetainedBytes, + ); + const pending = client.post( + endpoint, + SPARQL_QUERY_CONTENT_TYPE, + query, + timeoutMs, + signal, + { + maxRequestBytes: SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + maxResponseBytes: SYSTEM_RECORD_MAX_ATOMIC_INSPECTION_RESPONSE_BYTES, + }, + ); + query = null; + response = await pending; + } catch (error) { + client.replaceRequestRetainedBytes?.(0); + throw error; + } + if (response.status < 200 || response.status >= 300) { + response.replaceRetainedBytes?.(0); + throw new Error(`system-record projection inspection failed with HTTP ${response.status}`); + } + return parseAccountedInspection(response, { + scope: mode, + allowedSubjects: subjects, + maxRows: SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1 - 1, + }, retainParsed); +} + +function parseAccountedInspection( + response: ManagedHttpResponse, + input: Readonly<{ + scope: 'reserved' | 'shadow' | 'authoritative'; + allowedSubjects: readonly string[]; + maxRows: number; + }>, + retainParsed?: (quads: readonly Readonly[]) => void, +): readonly Readonly[] { + response.replaceRetainedBytes?.( + estimateSystemRecordInspectionParseBytesV1(response.body, input.maxRows), + ); + try { + const quads = parseSystemRecordInspectionResponseV1({ + body: response.body, + scope: input.scope, + allowedSubjects: input.allowedSubjects, + maxRows: input.maxRows, + }); + // Transfer synchronously from response workspace to retained preparation; + // no allocation or await occurs between the two accountant updates. + response.replaceRetainedBytes?.(0); + retainParsed?.(quads); + return quads; + } catch (error) { + response.replaceRetainedBytes?.(0); + throw error; + } +} + +function retainedObservedMaterializationBytes( + shape: ExactMaterializationV1, + reservedQuads: readonly Readonly[], + projectionQuads: readonly Readonly[], +): number { + // The observed shape reuses exactNext's subject arrays, but every decoded + // quad and its terms are newly retained by the exact post-read. + let bytes = 128 * (shape.reservedSubjects.length + shape.projectionSubjects.length); + for (const quad of [...reservedQuads, ...projectionQuads]) { + bytes += retainedQuadBytes(quad); + if (!Number.isSafeInteger(bytes)) return Number.MAX_SAFE_INTEGER; + } + return bytes; +} + +function retainedQuadBytes(quad: Readonly): number { + return 2 * ( + Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + Buffer.byteLength(quad.graph, 'utf8') + ) + 128; +} + +function fixedInitialSubjects(networkId: string, stableKeyHash: Digest32V1): readonly string[] { + return canonicalSubjects([ + systemRecordRecordSubjectV1(networkId, stableKeyHash), + systemRecordCapacitySubjectV1(networkId), + systemRecordEpochSubjectV1(networkId), + systemRecordReceiptSubjectV1(networkId, stableKeyHash), + ]); +} + +function rootClaimSubjects( + facts: SystemRecordVerifiedReplacementFactsV1, + snapshot: SystemRecordAppliedSnapshotV1, +): readonly string[] { + const roots = new Set([ + facts.head.rootSubject, + ...facts.verifiedAuthoritySummary.historicalRoots, + ]); + if (snapshot.state === 'present') { + roots.add(snapshot.rootClaimSet.currentRoot); + for (const root of snapshot.rootClaimSet.historicalRoots) roots.add(root); + } + return canonicalSubjects([...roots].map((root) => + systemRecordRootClaimSubjectV1(facts.networkId, root))); +} + +function assertCompletePriorBinding( + command: SystemRecordActiveReplacementCompleteV1, + snapshot: SystemRecordAppliedSnapshotV1, + rootClaimQuads: readonly Readonly[], +): void { + if (!equalQuads( + canonicalQuads(command.previousReservedQuads), + canonicalQuads([...snapshot.previousReservedQuads, ...rootClaimQuads]), + )) { + throw new Error('prepared apply does not carry the complete inspected prior tuple'); + } + if (snapshot.state === 'present') { + assertSystemRecordRootClaimSnapshotV1( + rootClaimQuads, + snapshot.expectedRootClaimQuads, + command.requiredAbsentReservedSubjects.filter((subject) => + rootClaimQuads.every((quad) => quad.subject !== subject)), + ); + } +} + +function exactMaterialization( + prepared: SystemRecordActiveReplacementCompleteV1, + subjectUnion: readonly string[], + mode: 'shadow' | 'authoritative', +): ExactMaterializationV1 { + const projectionGraph = systemRecordProjectionGraphV1(mode); + return Object.freeze({ + reservedSubjects: relevantReservedSubjects(prepared), + reservedQuads: canonicalQuads(prepared.nextReservedQuads), + projectionSubjects: subjectUnion, + // Registry verification already proves the projection is strict canonical + // UTF-8 order and duplicate-free. Adding one identical graph term preserves + // that order, so avoid a second 10,000-row sort while prior/prepared state is + // live under the transient lease. + projectionQuads: Object.freeze(prepared.nextProjectionQuads.map((quad) => + Object.freeze({ ...quad, graph: projectionGraph }))), + mode, + }); +} + +function relevantReservedSubjects(command: SystemRecordActiveReplacementCompleteV1): readonly string[] { + return canonicalSubjects([ + ...command.previousReservedQuads.map((quad) => quad.subject), + ...command.nextReservedQuads.map((quad) => quad.subject), + ...command.requiredAbsentReservedSubjects, + ...command.rootClaimGuards.map((guard) => guard.claimSubject), + ]); +} + +function matchesExact(actual: ExactMaterializationV1, expected: ExactMaterializationV1): boolean { + return equalQuads(actual.reservedQuads, expected.reservedQuads) + && equalQuads(actual.projectionQuads, expected.projectionQuads); +} + +export function fingerprintSystemRecordProjectionV1( + quads: readonly Readonly[], +): Readonly<{ + digest: Digest32V1; + bytes: string; + quads: string; +}> { + if (quads.length > SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1 - 1) { + throw new Error('inspected projection exceeds its quad bound'); + } + const digest = createHash('sha256'); + digest.update(KA_BUNDLE_PROJECTION_DIGEST_DOMAIN_V1, 'utf8'); + const newline = Buffer.from('\n'); + let previousLine: Uint8Array | undefined; + let projectionBytes = 0; + + for (const quad of quads) { + const line = tripleContentV10(quad.subject, quad.predicate, quad.object); + if (previousLine !== undefined && Buffer.compare(previousLine, line) >= 0) { + throw new Error('inspected projection is not in strict canonical line order'); + } + projectionBytes += line.byteLength + newline.byteLength; + if (!Number.isSafeInteger(projectionBytes) + || projectionBytes > SYSTEM_RECORD_MAX_PROJECTION_BYTES) { + throw new Error('inspected projection exceeds its canonical byte bound'); + } + digest.update(line); + digest.update(newline); + previousLine = line; + } + + return Object.freeze({ + digest: `0x${digest.digest('hex')}` as Digest32V1, + bytes: String(projectionBytes), + quads: String(quads.length), + }); +} + +function matchesProjectionSnapshot( + snapshot: SystemRecordAppliedSnapshotV1, + projectionQuads: readonly Readonly[], +): boolean { + const observed = fingerprintSystemRecordProjectionV1(projectionQuads); + const expected = snapshot.state === 'present' + ? snapshot.appliedState + : Object.freeze({ + projectionDigest: SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, + projectionBytes: '0', + projectionQuads: '0', + }); + return observed.digest === expected.projectionDigest + && observed.bytes === expected.projectionBytes + && observed.quads === expected.projectionQuads; +} + +function canonicalSubjects(values: readonly string[]): readonly string[] { + return Object.freeze([...new Set(values)].sort(compareSystemRecordUtf8V1)); +} + +function canonicalQuads(values: readonly Readonly[]): readonly Readonly[] { + return Object.freeze([...values].sort(compareQuad)); +} + +function equalQuads(left: readonly Readonly[], right: readonly Readonly[]): boolean { + return left.length === right.length + && left.every((quad, index) => compareQuad(quad, right[index]) === 0); +} + +function compareQuad(left: Readonly, right: Readonly): number { + return compareSystemRecordUtf8V1(left.graph, right.graph) + || compareSystemRecordUtf8V1(left.subject, right.subject) + || compareSystemRecordUtf8V1(left.predicate, right.predicate) + || compareSystemRecordUtf8V1(left.object, right.object); +} + +function boundedInspectionTimeout(deadlineMs: number, now: () => number): number { + const remaining = Math.floor(deadlineMs - now()); + if (remaining <= 0) throw new Error('system-record admitted inspection deadline elapsed'); + return Math.min(SYSTEM_RECORD_INSPECTION_TIMEOUT_MS, remaining); +} + +function boundedApplyTimeout(deadlineMs: number, now: () => number): number | null { + const remaining = Math.floor(deadlineMs - now()); + if (remaining <= 0) return null; + return Math.min(SYSTEM_RECORD_APPLY_TIMEOUT_MS, remaining); +} + +function classifyInspectionFailure( + error: unknown, +): Extract['reason'] { + const message = error instanceof Error ? error.message : String(error); + if (/row bound|byte bound|exceeds its .*bound|response body exceeded/iu.test(message)) { + return 'inspection-overflow'; + } + if (/aborted/iu.test(message)) return 'aborted'; + if (/timeout|timed out|deadline elapsed|exceeded [0-9]+ms/iu.test(message)) { + return 'inspection-timeout'; + } + return 'validation-mismatch'; +} + +function mapZeroWrite( + prepared: Exclude< + ReturnType, + SystemRecordActiveReplacementCompleteV1 + >, +): Exclude { + if (prepared.outcome === 'deferred') { + return { outcome: 'deferred', reason: 'validation-mismatch' }; + } + return { outcome: prepared.outcome }; +} + +function noMutation( + outcome: Exclude, +): SystemRecordAtomicApplySettlementV1 { + return Object.freeze({ settlement: 'no-mutation', outcome: Object.freeze(outcome) }); +} diff --git a/packages/storage/src/system-record-inspection-v1-internal.ts b/packages/storage/src/system-record-inspection-v1-internal.ts new file mode 100644 index 0000000000..e5b52cc8c2 --- /dev/null +++ b/packages/storage/src/system-record-inspection-v1-internal.ts @@ -0,0 +1,512 @@ +import { types as utilTypes } from 'node:util'; + +import { + assertSafeIri, + assertSafeRdfTerm, + escapeSparqlLiteral, + isSafeIri, + tripleContentV10, +} from '@origintrail-official/dkg-core'; +import { + SYSTEM_RECORD_MAX_ATOMIC_DECODED_TERM_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_INSPECTION_RESPONSE_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + SYSTEM_RECORD_MAX_OWNED_SUBJECTS, + SYSTEM_RECORD_MAX_PROJECTION_QUADS, + SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { Quad } from './triple-store.js'; +import { compareSystemRecordUtf8V1 } from './system-record-utf8-order-v1-internal.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from './internal-graph-policy.js'; +import { + systemRecordProjectionGraphV1, + type SystemRecordMaterializationModeV1, +} from './system-record-rdf-schema-v1-internal.js'; + +export const SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1 = 128; +export const SYSTEM_RECORD_MAX_RESERVED_INSPECTION_RESPONSE_BYTES_V1 = + SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES; +export const SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1 = + SYSTEM_RECORD_MAX_PROJECTION_QUADS + 1; + +const UTF8 = new TextEncoder(); +const TERM_KEYS = new Set(['type', 'value', 'datatype', 'xml:lang']); +const CONTAINER_ENTRY_BYTES_V1 = 128; + +export type SystemRecordInspectionQueryBuilderChargeV1 = (retainedBytes: number) => void; + +/** + * Conservative peak while JSON.parse and strict quad projection coexist. + * The scan is allocation-bounded and runs before JSON.parse, so adversarial + * arrays cannot allocate an uncharged object graph merely because their + * encoded body is inside the transport ceiling. + */ +export function estimateSystemRecordInspectionParseBytesV1( + body: string, + maxRows: number, + maxDecodedTermBytes = SYSTEM_RECORD_MAX_ATOMIC_DECODED_TERM_BYTES, +): number { + const bodyBytes = Buffer.byteLength(body, 'utf8'); + const structure = scanJsonStructure(body); + const possibleRows = Math.min(maxRows, structure.arrayEntries); + // Input JS text + JSON.parse-produced strings each use at most 2x encoded + // bytes. Canonical output terms can expand escaping, but remain bounded by + // the decoded-term ceiling and twice the encoded source size. + const possibleDecodedBytes = Math.min(maxDecodedTermBytes, bodyBytes * 2); + const result = bodyBytes * 4 + + possibleDecodedBytes * 3 + + (structure.containers + structure.arrayEntries + structure.objectEntries + possibleRows * 2) + * CONTAINER_ENTRY_BYTES_V1; + return Number.isSafeInteger(result) ? result : Number.MAX_SAFE_INTEGER; +} + +export function retainedSystemRecordInspectionQuadsBytesV1( + quads: readonly Readonly[], +): number { + let bytes = 0; + for (const quad of quads) { + bytes += 2 * ( + Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + Buffer.byteLength(quad.graph, 'utf8') + ) + CONTAINER_ENTRY_BYTES_V1; + if (!Number.isSafeInteger(bytes)) return Number.MAX_SAFE_INTEGER; + } + return bytes; +} + +function scanJsonStructure(body: string): Readonly<{ + containers: number; + arrayEntries: number; + objectEntries: number; +}> { + // Fixed workspace is load-bearing. This scan runs before the accountant can + // accept JSON.parse's object graph, so an attacker-controlled nesting stack + // would itself be an uncharged allocation proportional to the response body. + const kinds = new Uint8Array(SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH); + const commas = new Uint32Array(SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH); + const hasValue = new Uint8Array(SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH); + let depth = 0; + let containers = 0; + let arrayEntries = 0; + let objectEntries = 0; + let inString = false; + let escaped = false; + const closeArray = (index: number): void => { + if (hasValue[index] !== 0) arrayEntries += commas[index] + 1; + }; + for (let index = 0; index < body.length; index += 1) { + const code = body.charCodeAt(index); + if (inString) { + if (escaped) escaped = false; + else if (code === 0x5c) escaped = true; + else if (code === 0x22) inString = false; + continue; + } + if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) continue; + const current = depth - 1; + if (current >= 0 && kinds[current] === 1) { + if (code === 0x2c) commas[current] += 1; + else if (code !== 0x5d) hasValue[current] = 1; + } else if (current >= 0 && kinds[current] === 2 && code === 0x3a) { + objectEntries += 1; + } + if (code === 0x22) { + inString = true; + } else if (code === 0x5b || code === 0x7b) { + if (depth >= SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH) { + throw new Error('system-record inspection JSON exceeds its depth bound'); + } + containers += 1; + kinds[depth] = code === 0x5b ? 1 : 2; + commas[depth] = 0; + hasValue[depth] = 0; + depth += 1; + } else if (code === 0x5d || code === 0x7d) { + if (depth === 0 + || (code === 0x5d && kinds[depth - 1] !== 1) + || (code === 0x7d && kinds[depth - 1] !== 2)) { + throw new Error('system-record inspection JSON has mismatched structural delimiters'); + } + depth -= 1; + if (code === 0x5d) closeArray(depth); + } + } + for (let index = 0; index < depth; index += 1) { + if (kinds[index] === 1) closeArray(index); + } + return Object.freeze({ containers, arrayEntries, objectEntries }); +} + +export function buildSystemRecordReservedInspectionQueryV1( + subjects: readonly string[], + replaceBuilderCharge?: SystemRecordInspectionQueryBuilderChargeV1, +): string { + return buildExactSubjectQuery( + SYSTEM_RECORD_V1_STATE_GRAPH, + subjects, + SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1 + 1, + replaceBuilderCharge, + ); +} + +export function buildSystemRecordProjectionInspectionQueryV1( + mode: SystemRecordMaterializationModeV1, + subjects: readonly string[], + replaceBuilderCharge?: SystemRecordInspectionQueryBuilderChargeV1, +): string { + if (subjects.length < 1 || subjects.length > SYSTEM_RECORD_MAX_OWNED_SUBJECTS) { + throw new Error('system-record projection inspection subject count is outside its bound'); + } + return buildExactSubjectQuery( + systemRecordProjectionGraphV1(mode), + subjects, + SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1, + replaceBuilderCharge, + ); +} + +function buildExactSubjectQuery( + graph: string, + subjects: readonly string[], + limit: number, + replaceBuilderCharge?: SystemRecordInspectionQueryBuilderChargeV1, +): string { + assertSafeIri(graph); + const closed = snapshotSubjects(subjects); + const emit = (writer: InspectionQueryWriterV1): void => { + writer.add('SELECT ?s ?p ?o WHERE {\n'); + writer.add(` GRAPH <${graph}> {\n`); + writer.add(' VALUES ?s { '); + for (let index = 0; index < closed.length; index += 1) { + if (index > 0) writer.add(' '); + writer.add('<'); + writer.add(closed[index]); + writer.add('>'); + } + writer.add(' }\n'); + writer.add(' ?s ?p ?o .\n'); + writer.add(' }\n'); + writer.add(`}\nLIMIT ${limit}`); + }; + const counter = new CountingInspectionQueryWriterV1(); + emit(counter); + const writer = new BoundedInspectionQueryWriterV1(counter.bytes, replaceBuilderCharge); + emit(writer); + return writer.finish(); +} + +interface InspectionQueryWriterV1 { + add(value: string): void; +} + +class CountingInspectionQueryWriterV1 implements InspectionQueryWriterV1 { + private encodedBytes = 0; + + get bytes(): number { return this.encodedBytes; } + + add(value: string): void { + const bytes = Buffer.byteLength(value, 'utf8'); + if (bytes > SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES - this.encodedBytes) { + throw new Error('system-record inspection query exceeds the 4 MiB request bound'); + } + this.encodedBytes += bytes; + } +} + +class BoundedInspectionQueryWriterV1 implements InspectionQueryWriterV1 { + private buffer: Buffer | null; + private offset = 0; + + constructor( + private readonly encodedBytes: number, + private readonly replaceCharge?: SystemRecordInspectionQueryBuilderChargeV1, + ) { + replaceCharge?.(encodedBytes * 3); + this.buffer = Buffer.allocUnsafe(encodedBytes); + } + + add(value: string): void { + const bytes = Buffer.byteLength(value, 'utf8'); + if (this.buffer === null || bytes > this.encodedBytes - this.offset) { + throw new Error('system-record inspection query accounting mismatch'); + } + const written = this.buffer.write(value, this.offset, bytes, 'utf8'); + if (written !== bytes) throw new Error('system-record inspection query accounting mismatch'); + this.offset += written; + } + + finish(): string { + if (this.buffer === null || this.offset !== this.encodedBytes) { + throw new Error('system-record inspection query accounting mismatch'); + } + const result = this.buffer.toString('utf8'); + this.buffer = null; + this.replaceCharge?.(this.encodedBytes * 2); + if (Buffer.byteLength(result, 'utf8') !== this.encodedBytes) { + throw new Error('system-record inspection query accounting mismatch'); + } + return result; + } +} + +/** Strictly decode one bounded Oxigraph SPARQL JSON SELECT response. */ +export function parseSystemRecordInspectionResponseV1(input: { + readonly body: string; + readonly scope: 'reserved' | SystemRecordMaterializationModeV1; + readonly allowedSubjects: readonly string[]; + readonly maxRows: number; + readonly maxDecodedTermBytes?: number; +}): readonly Readonly[] { + const rawInput = dataRecord(input, 'system-record inspection input'); + const optional = Object.getOwnPropertyDescriptor(rawInput, 'maxDecodedTermBytes') + ? ['maxDecodedTermBytes'] as const + : [] as const; + const snapshot = exactRecord( + rawInput, + ['body', 'scope', 'allowedSubjects', 'maxRows', ...optional], + 'system-record inspection input', + ); + if (snapshot.scope !== 'reserved' && snapshot.scope !== 'shadow' + && snapshot.scope !== 'authoritative') { + throw new Error('system-record inspection scope is invalid'); + } + const scope = snapshot.scope as 'reserved' | SystemRecordMaterializationModeV1; + const graph = scope === 'reserved' + ? SYSTEM_RECORD_V1_STATE_GRAPH + : systemRecordProjectionGraphV1(scope); + const bodyCap = scope === 'reserved' + ? SYSTEM_RECORD_MAX_RESERVED_INSPECTION_RESPONSE_BYTES_V1 + : SYSTEM_RECORD_MAX_ATOMIC_INSPECTION_RESPONSE_BYTES; + if (typeof snapshot.body !== 'string') { + throw new Error('system-record inspection body must be text'); + } + if (Buffer.byteLength(snapshot.body, 'utf8') > bodyCap) { + throw new Error('system-record inspection response exceeds its encoded byte bound'); + } + const rowCap = scope === 'reserved' + ? SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1 + : SYSTEM_RECORD_MAX_PROJECTION_QUADS; + if (!Number.isSafeInteger(snapshot.maxRows) || (snapshot.maxRows as number) < 0 + || (snapshot.maxRows as number) > rowCap) { + throw new Error('system-record inspection row bound is invalid'); + } + const decodedCapValue = snapshot.maxDecodedTermBytes ?? SYSTEM_RECORD_MAX_ATOMIC_DECODED_TERM_BYTES; + if (typeof decodedCapValue !== 'number' || !Number.isSafeInteger(decodedCapValue) + || decodedCapValue < 0 + || decodedCapValue > SYSTEM_RECORD_MAX_ATOMIC_DECODED_TERM_BYTES) { + throw new Error('system-record inspection decoded-term bound is invalid'); + } + const decodedCap = decodedCapValue; + const subjects = snapshotSubjects(snapshot.allowedSubjects as readonly string[]); + const allowed = new Set(subjects); + let parsed: unknown; + try { + parsed = JSON.parse(snapshot.body); + } catch (cause) { + throw new Error('system-record inspection response is not JSON', { cause }); + } + const root = exactRecord(parsed, ['head', 'results'], 'SPARQL result'); + const head = exactRecord(root.head, ['vars'], 'SPARQL result head'); + const variables = closedArray(head.vars, 3, 'SPARQL result variables'); + if (variables.length !== 3 || new Set(variables).size !== 3 + || !['s', 'p', 'o'].every((name) => variables.includes(name))) { + throw new Error('system-record inspection response has unexpected variables'); + } + const results = exactRecord(root.results, ['bindings'], 'SPARQL result rows'); + const maxRows = snapshot.maxRows as number; + const rows = closedArray(results.bindings, maxRows + 1, 'SPARQL result bindings'); + if (rows.length > maxRows) throw new Error('system-record inspection response exceeds its row bound'); + + let decodedBytes = 0; + const quads = rows.map((candidate, index) => { + const row = exactRecord(candidate, ['s', 'p', 'o'], `SPARQL result row ${index}`); + const subject = parseUriBinding(row.s, `row ${index} subject`); + const predicate = parseUriBinding(row.p, `row ${index} predicate`); + if (!allowed.has(subject)) throw new Error('system-record inspection returned an unrequested subject'); + const object = parseObjectBinding(row.o, `row ${index} object`); + decodedBytes += UTF8.encode(subject).byteLength + + UTF8.encode(predicate).byteLength + + UTF8.encode(object).byteLength; + if (decodedBytes > decodedCap) { + throw new Error('system-record inspection response exceeds its decoded-term byte bound'); + } + return Object.freeze({ subject, predicate, object, graph }); + }); + if (scope === 'reserved') { + quads.sort(compareTupleQuad); + for (let index = 1; index < quads.length; index += 1) { + if (compareTupleQuad(quads[index - 1], quads[index]) === 0) { + throw new Error('system-record inspection response contains a duplicate quad'); + } + } + return Object.freeze(quads); + } + + // Materialized projection identity is canonical graphless N-Triples bytes, + // not raw tuple order. Precompute each line once: calling tripleContentV10 + // from an O(n log n) comparator would repeatedly encode the same maximum-size + // terms and turn a bounded sort into avoidable CPU/allocation churn. + const projectionOrder = quads.map((quad) => Object.freeze({ + quad, + line: tripleContentV10(quad.subject, quad.predicate, quad.object), + })); + projectionOrder.sort((left, right) => Buffer.compare(left.line, right.line)); + for (let index = 1; index < projectionOrder.length; index += 1) { + if (Buffer.compare(projectionOrder[index - 1].line, projectionOrder[index].line) === 0) { + throw new Error('system-record inspection response contains a duplicate quad'); + } + } + return Object.freeze(projectionOrder.map(({ quad }) => quad)); +} + +function snapshotSubjects(value: readonly string[]): readonly string[] { + const values = closedArray(value, SYSTEM_RECORD_MAX_OWNED_SUBJECTS, 'inspection subjects'); + if (values.length < 1) throw new Error('system-record inspection requires at least one subject'); + const copied = values.map((subject) => { + if (typeof subject !== 'string' || !isSafeIri(subject)) { + throw new Error('system-record inspection subject is not a safe IRI'); + } + return subject; + }); + copied.sort(compareSystemRecordUtf8V1); + for (let index = 1; index < copied.length; index += 1) { + if (copied[index - 1] === copied[index]) { + throw new Error('system-record inspection subjects must be unique'); + } + } + return Object.freeze(copied); +} + +function parseUriBinding(value: unknown, label: string): string { + const binding = exactTerm(value, label); + if (binding.type !== 'uri' || typeof binding.value !== 'string' || !isSafeIri(binding.value) + || Object.prototype.hasOwnProperty.call(binding, 'datatype') + || Object.prototype.hasOwnProperty.call(binding, 'xml:lang')) { + throw new Error(`${label} must be one safe IRI binding`); + } + return binding.value; +} + +function parseObjectBinding(value: unknown, label: string): string { + const binding = exactTerm(value, label); + if (binding.type === 'uri') { + if (typeof binding.value !== 'string' || !isSafeIri(binding.value) + || Object.prototype.hasOwnProperty.call(binding, 'datatype') + || Object.prototype.hasOwnProperty.call(binding, 'xml:lang')) { + throw new Error(`${label} URI is invalid`); + } + return binding.value; + } + if (binding.type !== 'literal' || typeof binding.value !== 'string') { + throw new Error(`${label} must be an IRI or literal; blank nodes are forbidden`); + } + assertUnicodeScalarString(binding.value, `${label} value`); + const hasDatatype = Object.prototype.hasOwnProperty.call(binding, 'datatype'); + const hasLanguage = Object.prototype.hasOwnProperty.call(binding, 'xml:lang'); + if (hasDatatype && hasLanguage) throw new Error(`${label} cannot have datatype and language`); + let term = `"${escapeSparqlLiteral(binding.value)}"`; + if (hasDatatype) { + if (typeof binding.datatype !== 'string' || !isSafeIri(binding.datatype)) { + throw new Error(`${label} datatype is invalid`); + } + term += `^^<${binding.datatype}>`; + } else if (hasLanguage) { + if (typeof binding['xml:lang'] !== 'string' + || !/^[A-Za-z]+(?:-[A-Za-z0-9]+)*$/.test(binding['xml:lang'])) { + throw new Error(`${label} language is invalid`); + } + term += `@${binding['xml:lang'].toLowerCase()}`; + } + assertSafeRdfTerm(term); + return term; +} + +function exactTerm(value: unknown, label: string): Record { + const term = dataRecord(value, label); + const keys = Reflect.ownKeys(term); + if (keys.some((key) => typeof key !== 'string' || !TERM_KEYS.has(key)) + || !Object.prototype.hasOwnProperty.call(term, 'type') + || !Object.prototype.hasOwnProperty.call(term, 'value')) { + throw new Error(`${label} has unknown or missing fields`); + } + return term; +} + +function exactRecord( + value: unknown, + expected: readonly string[], + label: string, +): Record { + const record = dataRecord(value, label); + const keys = Reflect.ownKeys(record); + if (keys.length !== expected.length + || keys.some((key) => typeof key !== 'string' || !expected.includes(key))) { + throw new Error(`${label} has unknown or missing fields`); + } + return record; +} + +function dataRecord(value: unknown, label: string): Record { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || utilTypes.isProxy(value) + || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { + throw new Error(`${label} must be a plain data object`); + } + const record = value as Record; + for (const key of Reflect.ownKeys(record)) { + const descriptor = Object.getOwnPropertyDescriptor(record, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} fields must be enumerable data properties`); + } + } + return record; +} + +function assertUnicodeScalarString(value: string, label: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0 || (code >= 0xd800 && code <= 0xdbff + && (index + 1 >= value.length + || value.charCodeAt(index + 1) < 0xdc00 + || value.charCodeAt(index + 1) > 0xdfff)) + || (code >= 0xdc00 && code <= 0xdfff + && (index === 0 + || value.charCodeAt(index - 1) < 0xd800 + || value.charCodeAt(index - 1) > 0xdbff))) { + throw new Error(`${label} contains a non-scalar Unicode value`); + } + if (code >= 0xd800 && code <= 0xdbff) index += 1; + } +} + +function closedArray(value: unknown, maxLength: number, label: string): unknown[] { + if (!Array.isArray(value) || !Number.isSafeInteger(value.length) || value.length > maxLength) { + throw new Error(`${label} must be a bounded array`); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1) throw new Error(`${label} must be a closed dense array`); + const result = new Array(value.length); + for (const key of keys) { + if (key === 'length') continue; + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) { + throw new Error(`${label} contains a non-index property`); + } + const index = Number(key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!Number.isSafeInteger(index) || index >= value.length || !descriptor?.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} must contain enumerable data elements`); + } + result[index] = descriptor.value; + } + return result; +} + +function compareTupleQuad(left: Readonly, right: Readonly): number { + return compareSystemRecordUtf8V1(left.subject, right.subject) + || compareSystemRecordUtf8V1(left.predicate, right.predicate) + || compareSystemRecordUtf8V1(left.object, right.object); +} diff --git a/packages/storage/src/system-record-materialization-epoch-v1-internal.ts b/packages/storage/src/system-record-materialization-epoch-v1-internal.ts new file mode 100644 index 0000000000..1d625c5e47 --- /dev/null +++ b/packages/storage/src/system-record-materialization-epoch-v1-internal.ts @@ -0,0 +1,230 @@ +import { + assertNetworkIdV1, + SYSTEM_RECORD_MATERIALIZER_HARD_TIMEOUT_MS, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import { OwnedManagedHttpClient } from './adapters/managed-http-client.js'; +import { + SPARQL_QUERY_CONTENT_TYPE, + SPARQL_UPDATE_CONTENT_TYPE, +} from './adapters/sparql-content-types.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from './internal-graph-policy.js'; +import { + managedOxigraphOwnershipEndpointsMatchV1, + readManagedOxigraphOwnershipSnapshotV1, + type ManagedOxigraphOwnershipLeaseV1, +} from './managed-oxigraph-ownership-v1-internal.js'; +import { + SYSTEM_RECORD_V1_PREDICATES, + systemRecordEpochSubjectV1, +} from './system-record-rdf-schema-v1-internal.js'; + +const MAX_U64 = 0xffff_ffff_ffff_ffffn; +const MAX_EPOCH_QUERY_BYTES = 4 * 1024; +const MAX_EPOCH_UPDATE_BYTES = 4 * 1024; +const MAX_EPOCH_RESPONSE_BYTES = 8 * 1024; + +export interface SystemRecordMaterializationEpochRotationV1 { + readonly epoch: string; + readonly childGeneration: string; +} + +export interface SystemRecordMaterializationEpochRotationInputV1 { + readonly networkId: string; + readonly lease: ManagedOxigraphOwnershipLeaseV1; + readonly client: OwnedManagedHttpClient; + readonly queryEndpoint: string; + readonly updateEndpoint: string; +} + +interface EpochReadResultV1 { + readonly value: string | null; +} + +const exactKeys = (value: unknown, keys: readonly string[], label: string): Record => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Reflect.ownKeys(value); + if ( + actual.length !== keys.length || + actual.some((key) => typeof key !== 'string' || !keys.includes(key)) + ) { + throw new Error(`${label} has unknown or missing fields`); + } + return value as Record; +}; + +const denseArray = (value: unknown, max: number, label: string): readonly unknown[] => { + if (!Array.isArray(value) || value.length > max) throw new Error(`${label} is invalid`); + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1 || !keys.includes('length')) { + throw new Error(`${label} must be a dense closed array`); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} must be a dense closed array`); + } + } + return value; +}; + +const canonicalU64 = (value: unknown, label: string): string => { + if (typeof value !== 'string' || !/^(0|[1-9][0-9]*)$/.test(value) || value.length > 20) { + throw new Error(`${label} must be a canonical decimal u64`); + } + if (BigInt(value) > MAX_U64) throw new Error(`${label} exceeds u64`); + return value; +}; + +const parseEpochResponse = (body: string): EpochReadResultV1 => { + let decoded: unknown; + try { + decoded = JSON.parse(body); + } catch (cause) { + throw new Error('materialization epoch query returned invalid JSON', { cause }); + } + const root = exactKeys(decoded, ['head', 'results'], 'materialization epoch query response'); + const head = exactKeys(root.head, ['vars'], 'materialization epoch query head'); + const variables = denseArray(head.vars, 1, 'materialization epoch query variables'); + if (variables.length !== 1 || variables[0] !== 'epoch') { + throw new Error('materialization epoch query returned an unexpected variable set'); + } + const results = exactKeys(root.results, ['bindings'], 'materialization epoch query results'); + const bindings = denseArray(results.bindings, 2, 'materialization epoch query bindings'); + if (bindings.length === 0) return Object.freeze({ value: null }); + if (bindings.length !== 1) { + throw new Error('materialization epoch has multiple persisted values'); + } + const row = exactKeys(bindings[0], ['epoch'], 'materialization epoch query row'); + const term = exactKeys(row.epoch, ['type', 'value'], 'materialization epoch query term'); + if (term.type !== 'literal') { + throw new Error('materialization epoch must be a plain literal'); + } + return Object.freeze({ value: canonicalU64(term.value, 'materialization epoch') }); +}; + +const assertOwnedGeneration = ( + input: SystemRecordMaterializationEpochRotationInputV1, + expectedGeneration: string, +): void => { + const snapshot = readManagedOxigraphOwnershipSnapshotV1(input.lease); + if ( + snapshot === null || + snapshot.terminal || + !snapshot.ready || + snapshot.childGeneration !== expectedGeneration || + input.client.childGeneration !== expectedGeneration || + !managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + input.queryEndpoint, + input.updateEndpoint, + ) + ) { + throw new Error('managed Oxigraph ownership changed during materialization epoch rotation'); + } +}; + +const epochQuery = (subject: string): string => + `SELECT ?epoch WHERE { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ` + + `<${subject}> <${SYSTEM_RECORD_V1_PREDICATES.materializationEpoch}> ?epoch . } } LIMIT 2`; + +const epochUpdate = (subject: string, previous: string | null, next: string): string => { + const triple = `<${subject}> <${SYSTEM_RECORD_V1_PREDICATES.materializationEpoch}>`; + if (previous === null) { + return `INSERT { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ${triple} "${next}" . } } ` + + `WHERE { FILTER NOT EXISTS { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ${triple} ?existing . } } }`; + } + return `DELETE { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ${triple} "${previous}" . } } ` + + `INSERT { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ${triple} "${next}" . } } ` + + `WHERE { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ${triple} "${previous}" . } ` + + `FILTER NOT EXISTS { GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { ${triple} ?other . ` + + `FILTER (?other != "${previous}") } } }`; +}; + +async function readEpoch( + input: SystemRecordMaterializationEpochRotationInputV1, + expectedGeneration: string, + query: string, +): Promise { + assertOwnedGeneration(input, expectedGeneration); + const response = await input.client.post( + input.queryEndpoint, + SPARQL_QUERY_CONTENT_TYPE, + query, + SYSTEM_RECORD_MATERIALIZER_HARD_TIMEOUT_MS, + undefined, + { + maxRequestBytes: MAX_EPOCH_QUERY_BYTES, + maxResponseBytes: MAX_EPOCH_RESPONSE_BYTES, + }, + ); + assertOwnedGeneration(input, expectedGeneration); + if (response.status < 200 || response.status >= 300) { + throw new Error(`materialization epoch query failed with HTTP ${response.status}`); + } + return parseEpochResponse(response.body); +} + +/** + * Durably advance the per-network agents materialization epoch. + * + * This function is called only inside the store-wide control barrier. It uses + * the generation-owned client directly and therefore never re-enters the + * ordinary store scheduler. The post-read is authoritative: it resolves a + * response lost after commit, while every unchanged or competing state fails + * closed before an activation facade can be published. + */ +export async function rotateSystemRecordMaterializationEpochV1( + input: SystemRecordMaterializationEpochRotationInputV1, +): Promise { + const initial = readManagedOxigraphOwnershipSnapshotV1(input.lease); + if (initial === null) throw new Error('managed Oxigraph ownership lease is unavailable'); + const expectedGeneration = initial.childGeneration; + assertOwnedGeneration(input, expectedGeneration); + + assertNetworkIdV1(input.networkId); + const subject = systemRecordEpochSubjectV1(input.networkId); + const query = epochQuery(subject); + const before = await readEpoch(input, expectedGeneration, query); + const previous = before.value === null ? 0n : BigInt(before.value); + if (previous === MAX_U64) throw new Error('materialization epoch cannot advance beyond u64'); + const next = (previous + 1n).toString(10); + const update = epochUpdate(subject, before.value, next); + + assertOwnedGeneration(input, expectedGeneration); + let updateFailure: unknown = null; + try { + const response = await input.client.post( + input.updateEndpoint, + SPARQL_UPDATE_CONTENT_TYPE, + update, + SYSTEM_RECORD_MATERIALIZER_HARD_TIMEOUT_MS, + undefined, + { + maxRequestBytes: MAX_EPOCH_UPDATE_BYTES, + maxResponseBytes: MAX_EPOCH_RESPONSE_BYTES, + }, + ); + if (response.status < 200 || response.status >= 300) { + updateFailure = new Error(`materialization epoch update failed with HTTP ${response.status}`); + } + } catch (error) { + updateFailure = error; + } + + // Recheck before the recovery read. A listener replacement after an + // indeterminate update must never be mistaken for the store that received it. + assertOwnedGeneration(input, expectedGeneration); + const after = await readEpoch(input, expectedGeneration, query); + assertOwnedGeneration(input, expectedGeneration); + if (after.value !== next) { + throw new Error( + `materialization epoch rotation did not commit the expected value ${next}`, + updateFailure === null ? undefined : { cause: updateFailure }, + ); + } + + return Object.freeze({ epoch: next, childGeneration: expectedGeneration }); +} diff --git a/packages/storage/src/system-record-materializer-v1.ts b/packages/storage/src/system-record-materializer-v1.ts index c5642a8c6a..4d565600de 100644 --- a/packages/storage/src/system-record-materializer-v1.ts +++ b/packages/storage/src/system-record-materializer-v1.ts @@ -2,6 +2,13 @@ import { readManagedOxigraphOwnershipSnapshotV1, type ManagedOxigraphOwnershipLeaseV1, } from './managed-oxigraph-ownership-v1-internal.js'; +import type { + SystemRecordAtomicApplySettlementV1, + SystemRecordAtomicRecoveryRegistrarV1, + SystemRecordAtomicRecoveryRequestV1, + SystemRecordAtomicRecoveryResolutionV1, + SystemRecordAtomicRecoveryRuntimeV1, +} from './system-record-atomic-apply-executor-v1-internal.js'; /** * System-record V1 lane controller (#2052 Stack B2). @@ -78,7 +85,7 @@ export type SystemRecordLaneStateV1 = export interface SystemRecordLaneSessionV1 { readonly state: SystemRecordLaneStateV1; - /** Activation generation. Increments on every successful enable. */ + /** The activation generation this facade was created for. */ readonly activationGeneration: string; applyVerified(proof: unknown): Promise; close(mode: 'disable' | 'shutdown'): Promise; @@ -100,19 +107,76 @@ export interface SystemRecordLaneControllerV1 { */ export interface SystemRecordChildHandoffV1 { /** Destroy the generation-specific HTTP client and its owned sockets. */ - destroyClient(): Promise; + destroyClient(absoluteDeadlineMs?: number): Promise; /** Stop the owned child and PROVE exit plus port release. Reject if unproven. */ - stopAndProveOwnedChildDead(): Promise; + stopAndProveOwnedChildDead(absoluteDeadlineMs?: number): Promise; /** Await every promise and permit issued against the retired generation. */ - awaitRetiredWork(): Promise; + awaitRetiredWork(absoluteDeadlineMs?: number): Promise; /** Start a replacement child and prove it is the ready listener. */ - startAndProveCleanGeneration(): Promise; - /** Rotate the materialization epoch under exclusive control. */ - rotateMaterializationEpoch(): Promise; + startAndProveCleanGeneration(absoluteDeadlineMs?: number): Promise; + /** Permanently refuse ordinary managed mutations after an unproven transition. */ + failManagedMutationsClosed?(reason: string): void; + /** + * Durably rotate the per-network agents epoch under exclusive control. + * + * The optional argument and void result preserve the B2 handoff shape. A + * void result is accepted only for an executor without the B3 settlement + * boundary; the controller then derives the proven child generation from + * its lease and keeps the missing epoch behind an internal legacy marker. + * A B3 activation still requires the concrete binding. + */ + rotateMaterializationEpoch(networkId?: string): Promise; + /** + * Bind the exact-recovery read to the proven replacement generation. + * Optional only while the B2 adapter migrates to the B3 atomic executor; + * an uncertain B3 write without it fails terminally closed. + */ + createRecoveryRuntime?( + binding: SystemRecordLaneExecutionBindingV1, + absoluteDeadlineMs: number, + signal: AbortSignal, + ): SystemRecordAtomicRecoveryRuntimeV1; } export interface SystemRecordTransactionExecutorV1 { applyVerified(proof: unknown, childGeneration: string): Promise; + /** Release an authentic proof that lifecycle admission refused before dispatch. */ + discardVerified?(proof: unknown): void; + /** + * Preferred activation-bound entry point. + * + * Optional only while the storage adapter migrates from the B2 + * child-generation-only contract. The lane always performs the complete + * facade binding check itself; an executor that implements this method also + * receives the frozen facts needed to repeat that check at dispatch. + */ + applyVerifiedBound?( + proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + ): Promise; + /** + * B3 transaction boundary. Mutation settlement is explicit and an uncertain + * write transfers ownership through `registerRecovery` while the executor's + * exclusive scheduler permit is still live. + */ + applyVerifiedSettlementBound?( + proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + registerRecovery: SystemRecordAtomicRecoveryRegistrarV1, + ): Promise; +} + +export interface SystemRecordLaneExecutionBindingV1 { + readonly activationGeneration: string; + readonly networkId: string; + readonly kind: 'agents'; + readonly mode: 'shadow' | 'authoritative'; + readonly sessionIdentity: object; + readonly childGeneration: string; + readonly materializationEpoch: string; } /** @@ -148,6 +212,13 @@ export interface SystemRecordLaneControllerDepsV1 { * child while ordinary requests were still in flight. */ readonly barrier: SystemRecordLaneBarrierV1; + /** + * Adapter-owned admission latch, driven by the lifecycle's physical state. + * `true` is published synchronously before enable can enqueue its barrier; + * `false` is published only after disable physically commits or the lane is + * terminally unavailable. Merely constructing the controller never calls it. + */ + readonly setAdmissionActive?: (active: boolean) => void; } /** Raised when an incompatible activation descriptor is offered to a live session. */ @@ -176,8 +247,85 @@ export class SystemRecordControllerRegistrationError extends Error { } } -const descriptorOf = (activation: SystemRecordLaneActivationV1): string => - `${activation.networkId}|${[...activation.kinds].sort().join(',')}|${activation.mode}`; +interface SystemRecordLaneActivationSnapshotV1 { + readonly networkId: string; + readonly kind: 'agents'; + readonly mode: 'shadow' | 'authoritative'; +} + +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. */ +const snapshotActivation = (activation: unknown): SystemRecordLaneActivationSnapshotV1 => { + if ( + activation === null || + typeof activation !== 'object' || + Array.isArray(activation) || + ![Object.prototype, null].includes(Object.getPrototypeOf(activation)) + ) { + throw new Error('system-record lane activation must be a plain data object'); + } + + const expected = ['kinds', 'mode', 'networkId']; + const ownKeys = Reflect.ownKeys(activation); + if ( + ownKeys.length !== expected.length || + ownKeys.some((key) => typeof key !== 'string') || + [...(ownKeys as string[])].sort().some((key, index) => key !== expected[index]) + ) { + throw new Error('system-record lane activation has unknown or missing fields'); + } + + const readDataField = (key: string): unknown => { + const field = Object.getOwnPropertyDescriptor(activation, key); + if (!field?.enumerable || !Object.prototype.hasOwnProperty.call(field, 'value')) { + throw new Error('system-record lane activation fields must be enumerable data properties'); + } + return field.value; + }; + + const networkId = readDataField('networkId'); + if ( + typeof networkId !== 'string' || + networkId.length === 0 || + UTF8.encode(networkId).byteLength > MAX_NETWORK_ID_BYTES_V1 || + !NETWORK_ID_PATTERN_V1.test(networkId) + ) { + throw new Error('system-record lane activation networkId is not canonical'); + } + + const kinds = readDataField('kinds'); + if (!Array.isArray(kinds)) { + throw new Error('system-record lane activation kinds must be the closed [agents] tuple'); + } + const kindKeys = Reflect.ownKeys(kinds); + const length = Object.getOwnPropertyDescriptor(kinds, 'length'); + const first = Object.getOwnPropertyDescriptor(kinds, '0'); + if ( + kindKeys.length !== 2 || + !kindKeys.includes('length') || + !kindKeys.includes('0') || + length?.value !== 1 || + length.enumerable || + !first?.enumerable || + !Object.prototype.hasOwnProperty.call(first, 'value') || + first.value !== 'agents' + ) { + throw new Error('system-record lane activation kinds must be the closed [agents] tuple'); + } + + const mode = readDataField('mode'); + if (mode !== 'shadow' && mode !== 'authoritative') { + throw new Error('system-record lane activation mode is invalid'); + } + + return Object.freeze({ networkId, kind: 'agents', mode }); +}; + +const descriptorOf = (activation: SystemRecordLaneActivationSnapshotV1): string => + `${activation.networkId}|${activation.kind}|${activation.mode}`; /** * Process-global single-registration invariant. @@ -197,12 +345,50 @@ const descriptorOf = (activation: SystemRecordLaneActivationV1): string => * into the scheduler would create a second one to drift out of sync. */ let registeredController: SystemRecordLaneControllerV1 | null = null; +const controllerSessions = new WeakMap(); /** Test-only reset. Never called from production code. */ export function __resetSystemRecordControllerRegistrationForTests(): void { registeredController = null; } +/** + * Release a controller that was only discovered and never opened. + * + * Store capability probes are intentionally lazy, but a successful probe still + * reserves the process-global controller slot. Closing that store before an + * activation must release the reservation without performing a child restart. + * The session is terminally latched first so a retained controller reference + * cannot be opened after a replacement store has claimed the slot. + */ +export function releasePassiveSystemRecordLaneControllerV1( + controller: SystemRecordLaneControllerV1, +): boolean { + if (registeredController !== controller) return false; + const session = controllerSessions.get(controller); + if (!session?.releasePassive()) return false; + registeredController = null; + controllerSessions.delete(controller); + return true; +} + +/** + * Store-owner disposal. Passive probes release without touching the child; + * activated controllers run the one coalesced shutdown transition so no live + * session can retain the process-global slot after its adapter is gone. + */ +export async function disposeSystemRecordLaneControllerV1( + controller: SystemRecordLaneControllerV1, +): Promise { + if (registeredController !== controller) return false; + if (releasePassiveSystemRecordLaneControllerV1(controller)) return true; + const session = controllerSessions.get(controller); + if (!session) return false; + await session.close('shutdown'); + controllerSessions.delete(controller); + return true; +} + export function createSystemRecordLaneControllerV1( deps: SystemRecordLaneControllerDepsV1, ): SystemRecordLaneControllerV1 { @@ -214,6 +400,7 @@ export function createSystemRecordLaneControllerV1( }); session.owner = controller; registeredController = controller; + controllerSessions.set(controller, session); return controller; } @@ -221,13 +408,128 @@ export function createSystemRecordLaneControllerV1( * The one aggregate session * ------------------------------------------------------------------ */ -class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { +interface SystemRecordLaneFacadeBindingV1 { + readonly descriptor: string; + readonly activationGeneration: string; + readonly networkId: string; + readonly kind: 'agents'; + readonly mode: 'shadow' | 'authoritative'; + readonly sessionIdentity: object; + readonly childGeneration: string; + readonly materializationEpoch: string; +} + +interface SystemRecordPendingRecoveryV1 { + readonly request: SystemRecordAtomicRecoveryRequestV1; + readonly recoveryGeneration: string; + readonly absoluteDeadlineMs: number; + readonly completion: Promise; + readonly resolve: (resolution: SystemRecordAtomicRecoveryResolutionV1) => void; + readonly exactReadAbort: AbortController; + readonly physicalSettlement: SystemRecordPhysicalSettlementV1; + settled: boolean; +} + +interface SystemRecordPhysicalSettlementV1 { + state: + | 'unsettled' + | 'owned-child-dead' + | 'old-generation-dead' + | 'replacement-live' + | 'all-generations-dead'; +} + +type SystemRecordLaneTransitionV1 = + | { + readonly kind: 'open'; + readonly descriptor: string; + readonly work: Promise; + } + | { + readonly kind: 'disable'; + readonly descriptor: null; + work: Promise; + settlement: Promise; + physicalWork: Promise | null; + physicalSettled: boolean; + reportedSettled: boolean; + recovery: SystemRecordPendingRecoveryV1 | null; + } + | { + readonly kind: 'shutdown'; + readonly descriptor: null; + work: Promise; + settlement: Promise; + physicalWork: Promise | null; + physicalSettled: boolean; + physicalSucceeded: boolean; + reportedSettled: boolean; + recovery: SystemRecordPendingRecoveryV1 | null; + } + | { + readonly kind: 'recovery'; + readonly descriptor: null; + work: Promise; + physicalWork: Promise | null; + physicalSettled: boolean; + readonly recovery: SystemRecordPendingRecoveryV1; + shutdownTeardownComplete: boolean; + }; + +const SYSTEM_RECORD_RECOVERY_DEADLINE_MS_V1 = 30_000; + +// B2 exposed neither an epoch binding nor a settlement-bound executor. Keep +// its absent epoch distinguishable from every canonical decimal V1 epoch while +// the facade/session identity and lease generation retain admission authority. +const INTERNAL_B2_MATERIALIZATION_EPOCH_SENTINEL_V1 = + 'internal:b2-materialization-epoch-unavailable'; + +/** + * An immutable view of one successful activation. + * + * Lifecycle remains aggregate: closing any facade still disables or shuts + * down the one physical lane. Admission is activation-scoped: applying through + * a facade from before a disable/reopen is refused even when the descriptor is + * unchanged and the aggregate is enabled again. + */ +class SystemRecordLaneFacade implements SystemRecordLaneSessionV1 { + constructor( + private readonly aggregate: SystemRecordLaneSession, + private readonly binding: SystemRecordLaneFacadeBindingV1, + ) { + Object.freeze(this); + } + + get state(): SystemRecordLaneStateV1 { + return this.aggregate.state; + } + + get activationGeneration(): string { + return this.binding.activationGeneration; + } + + applyVerified(proof: unknown): Promise { + return this.aggregate.applyVerifiedForBinding(proof, this.binding); + } + + close(mode: 'disable' | 'shutdown'): Promise { + return this.aggregate.close(mode); + } +} + +class SystemRecordLaneSession { private current: SystemRecordLaneStateV1 = 'disabled'; private activation = 0n; private descriptor: string | null = null; + private activeNetworkId: string | null = null; + private activeSessionIdentity: object | null = null; + private activeChildGeneration: string | null = null; + private activeMaterializationEpoch: string | null = null; + /** One facade per activation binding; a new generation gets a new facade. */ + private activeFacade: SystemRecordLaneSessionV1 | null = null; /** In-flight transition, so same-intent callers coalesce instead of racing. */ - private transition: { kind: 'open' | 'disable' | 'shutdown'; descriptor: string | null; work: Promise } | null = - null; + private transition: SystemRecordLaneTransitionV1 | null = null; + private recoverySequence = 0n; /** * The controller this session backs, so shutdown can release the @@ -245,10 +547,24 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { return this.activation.toString(10); } + /** Called only by the owning store during close, before any activation. */ + releasePassive(): boolean { + if ( + this.current !== 'disabled' || + this.activation !== 0n || + this.descriptor !== null || + this.transition !== null + ) return false; + this.current = 'shutdown'; + this.owner = null; + return true; + } + /* -------------------------------------------------------------- */ async open(activation: SystemRecordLaneActivationV1): Promise { - const wanted = descriptorOf(activation); + const activationSnapshot = snapshotActivation(activation); + const wanted = descriptorOf(activationSnapshot); this.assertNotTerminal(); @@ -260,7 +576,7 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { await this.transition.work; // Re-check: the joined open may itself have failed into `unavailable`. this.assertNotTerminal(); - return this; + return this.createFacade(wanted, activationSnapshot); } if (this.transition.kind !== 'open') { // Only a DISABLE can be here now: a shutdown makes `current` terminal at @@ -273,7 +589,7 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // that: state `enabled`, activation generation 2, and a subsequent // `applyVerified` dispatched to the executor on a lane the process had // already shut down. - await this.transition.work.catch(() => undefined); + await this.transitionSettlement(this.transition).catch(() => undefined); this.assertNotTerminal(); } else { throw new SystemRecordLaneActivationConflictError( @@ -284,11 +600,15 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { } if (this.current === 'enabled') { - if (this.descriptor === wanted) return this; + if (this.descriptor === wanted) return this.createFacade(wanted, activationSnapshot); throw new SystemRecordLaneActivationConflictError(this.descriptor ?? 'unknown', wanted); } - const entry = { kind: 'open' as const, descriptor: wanted, work: this.runEnable(wanted) }; + const entry = { + kind: 'open' as const, + descriptor: wanted, + work: this.runEnable(wanted, activationSnapshot), + }; this.transition = entry; try { await entry.work; @@ -300,7 +620,7 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // session whose `state` is already `shutdown`, as though the open had // succeeded. this.assertNotTerminal(); - return this; + return this.createFacade(wanted, activationSnapshot); } /** @@ -317,34 +637,71 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { * falling back to the legacy lane while that uncertainty stands is exactly * the bypass the design forbids. */ - private async runEnable(descriptor: string): Promise { + private async runEnable( + descriptor: string, + activation: SystemRecordLaneActivationSnapshotV1, + ): Promise { this.assertLeaseLive(); this.commitState('enabling'); + let rotated: void | { readonly epoch: string; readonly childGeneration: string }; try { // Under the barrier: admission is sealed and both tagged and untagged // work is drained before the child is touched, and not resumed until the // replacement generation is bound. - await this.deps.barrier('system-record.enable', async () => { + rotated = await this.deps.barrier('system-record.enable', async () => { await this.deps.handoff.destroyClient(); await this.deps.handoff.stopAndProveOwnedChildDead(); await this.deps.handoff.awaitRetiredWork(); await this.deps.handoff.startAndProveCleanGeneration(); - await this.deps.handoff.rotateMaterializationEpoch(); + return this.deps.handoff.rotateMaterializationEpoch(activation.networkId); }); } catch (error) { + this.failManagedMutationsClosed('enable transition did not physically settle'); this.commitState('unavailable'); this.descriptor = null; + this.clearActiveBinding(); throw error; } + if ( + (!rotated && this.deps.executor.applyVerifiedSettlementBound) || + (rotated && ( + typeof rotated.epoch !== 'string' || + typeof rotated.childGeneration !== 'string' + )) + ) { + this.failManagedMutationsClosed( + 'enable transition did not return a materialization epoch binding', + ); + this.commitState('unavailable'); + this.descriptor = null; + this.clearActiveBinding(); + throw new Error( + 'system-record lane enable did not return a materialization epoch binding', + ); + } // POST-CONDITION: the handoff claims to have started and proved a clean // generation, so verify that rather than assume it. Without this, // `runEnable` moved to `enabled` purely because no step threw, and a // handoff that resolved without binding a ready generation produced an // "enabled" lane over a child that was not the proven listener. const bound = readManagedOxigraphOwnershipSnapshotV1(this.deps.lease); - if (!bound || bound.terminal || !bound.ready) { + const activationBinding = rotated ?? (bound && !bound.terminal && bound.ready + ? Object.freeze({ + epoch: INTERNAL_B2_MATERIALIZATION_EPOCH_SENTINEL_V1, + childGeneration: bound.childGeneration, + }) + : undefined); + if ( + !bound || + bound.terminal || + !bound.ready || + !activationBinding || + bound.childGeneration !== activationBinding.childGeneration + ) { + this.failManagedMutationsClosed('enable transition did not prove replacement ownership'); this.commitState('unavailable'); this.descriptor = null; + this.clearActiveBinding(); throw new Error( 'system-record lane enable completed without a proven-ready child generation ' + `(${bound ? `ready=${bound.ready} terminal=${bound.terminal}` : 'no lease'})`, @@ -360,6 +717,10 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { if (!this.commitState('enabled')) return; this.activation += 1n; this.descriptor = descriptor; + this.activeNetworkId = activation.networkId; + this.activeSessionIdentity = Object.freeze(Object.create(null) as object); + this.activeChildGeneration = activationBinding.childGeneration; + this.activeMaterializationEpoch = activationBinding.epoch; } /* -------------------------------------------------------------- */ @@ -371,15 +732,25 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { if (this.current === 'disabled' || this.current === 'unavailable') return; if (this.transition) { - if (this.transition.kind === 'disable') return this.transition.work; + if (this.transition.kind === 'disable') { + return this.transition.reportedSettled + ? this.transition.settlement + : this.transition.work; + } // A `kind === 'shutdown'` branch used to sit here. Under the latch it is // unreachable: a shutdown makes `current` terminal at intent, so the // `current === 'shutdown'` check above has already returned. Deleted // rather than kept, because a branch that can no longer change an outcome // reads as protection. // - // Disable outranks an in-flight open: join it, then disable the result. - await this.transition.work.catch(() => undefined); + // Disable outranks recovery at INTENT. Latch `disabling` before joining + // so the recovery continuation cannot briefly republish `enabled` and + // admit legacy/V1 work between settlement and this close. + if (this.transition.kind === 'recovery') this.commitState('disabling'); + // Disable outranks an in-flight open/recovery: join it, then disable the + // resulting clean generation. The recovery itself cannot be skipped; + // physical ambiguity must settle before legacy can bypass. + await this.transitionSettlement(this.transition).catch(() => undefined); // The joined open may have ended terminal, or a shutdown may have latched // while we waited. Re-read rather than acting on stale state. const after = this.readState(); @@ -390,16 +761,24 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // `system-record.disable` section and rotated the materialization epoch // twice for one disable. const raced = this.readTransition(); - if (raced?.kind === 'disable') return raced.work; + if (raced?.kind === 'disable') { + return raced.reportedSettled ? raced.settlement : raced.work; + } } - const entry = { kind: 'disable' as const, descriptor: null, work: this.runDisable() }; + const entry: Extract = { + kind: 'disable', + descriptor: null, + recovery: null, + work: Promise.resolve(), + settlement: Promise.resolve(), + physicalWork: null, + physicalSettled: false, + reportedSettled: false, + }; this.transition = entry; - try { - await entry.work; - } finally { - this.release(entry); - } + entry.work = this.runDisable(entry); + return entry.work; } /** @@ -408,19 +787,92 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { * before legacy callers are allowed to bypass, or a retired request could * commit after legacy work has already read around it. */ - private async runDisable(): Promise { + private runDisable( + entry: Extract, + ): Promise { this.commitState('disabling'); - try { - await this.deps.barrier('system-record.disable', async () => { - await this.deps.handoff.awaitRetiredWork(); - await this.deps.handoff.rotateMaterializationEpoch(); - }); - } catch (error) { + const networkId = this.activeNetworkId; + if (networkId === null) { this.commitState('unavailable'); - throw error; + const failure = Promise.reject( + new Error('system-record lane has no active network binding to disable'), + ); + entry.reportedSettled = true; + entry.settlement = failure; + void failure.catch(() => undefined); + this.release(entry); + return failure; } - this.descriptor = null; - this.commitState('disabled'); + + const work = (async () => { + try { + await this.deps.barrier('system-record.disable', () => { + // Keep the callback promise separately from the barrier's public + // promise. A transition timeout reports to this caller while the + // callback remains the exclusive physical owner. + const physicalWork = (async () => { + try { + // If an already-running apply transferred uncertainty after disable + // sealed admission, the request attaches to THIS barrier. Enqueueing a + // second recovery barrier behind it would let this close rotate/return + // before physical settlement and reopen the legacy bypass window. + if (entry.recovery) { + const recovered = await this.recoverInsideBarrier(entry.recovery, 'disable'); + if (recovered.resolution === 'unavailable') { + throw new Error( + 'system-record uncertain write could not be settled during disable', + ); + } + } else { + await this.deps.handoff.awaitRetiredWork(); + } + await this.deps.handoff.rotateMaterializationEpoch(networkId); + this.descriptor = null; + this.clearActiveBinding(); + this.commitState('disabled'); + } catch (error) { + this.failManagedMutationsClosed('disable transition did not physically settle'); + this.commitState('unavailable'); + throw error; + } finally { + entry.physicalSettled = true; + } + })(); + entry.physicalWork = physicalWork; + return physicalWork; + }); + } catch (error) { + // A wait timeout never invoked the callback. Once it has started, the + // callback itself owns the fail-closed state and retained settlement. + if (entry.physicalWork === null) { + this.failManagedMutationsClosed('disable transition did not physically settle'); + this.commitState('unavailable'); + this.descriptor = null; + this.clearActiveBinding(); + } + throw error; + } finally { + entry.reportedSettled = true; + if (entry.physicalWork === null) this.release(entry); + } + })(); + + entry.work = work; + entry.settlement = (async () => { + try { + await work; + } catch (reportedError) { + if (entry.physicalWork) await entry.physicalWork; + else throw reportedError; + } + })().finally(() => { + // Preserve an unresolved recovery token for a later shutdown attempt. + // Its executor reservation remains charged, and losing this pointer would + // make a subsequently successful physical teardown unable to settle it. + if (entry.recovery === null || entry.recovery.settled) this.release(entry); + }); + void entry.settlement.catch(() => undefined); + return work; } /** @@ -452,7 +904,11 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // get a RESOLVED promise while the child was still being stopped, and would // never see the teardown's failure. Joining first keeps "close('shutdown') // resolved" meaning "the teardown finished". - if (this.transition?.kind === 'shutdown') return this.transition.work; + if (this.transition?.kind === 'shutdown') { + return this.transition.reportedSettled + ? this.transition.settlement + : this.transition.work; + } if (this.readState() === 'shutdown') return Promise.resolve(); // THE LATCH. Shutdown intent becomes the committed state SYNCHRONOUSLY — @@ -472,6 +928,48 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // terminal lane and returns `capability-lost` instead of being admitted. this.current = 'shutdown'; + const superseded = this.transition; + // If exact settlement is already reading the replacement generation, make + // shutdown cancel that owned request NOW. The transition below still joins + // the superseded work before touching the child, so cancellation cannot + // leave an HTTP promise running after the control barrier releases. + const supersededRecovery = this.recoveryOf(superseded); + supersededRecovery?.exactReadAbort.abort( + new Error('system-record exact recovery cancelled by shutdown'), + ); + const entry: Extract = { + kind: 'shutdown', + descriptor: null, + recovery: supersededRecovery, + work: Promise.resolve(), + settlement: Promise.resolve(), + physicalWork: null, + physicalSettled: false, + physicalSucceeded: false, + reportedSettled: false, + }; + this.transition = entry; + + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + this.descriptor = null; + this.clearActiveBinding(); + // A callback failure OR a wait-phase timeout leaves the process unable + // to prove the child/client boundary. Keep both the transition and + // process-global registration claimed so later closes join the same + // failure and no replacement controller can be created in this process. + if (!entry.physicalSucceeded) return; + cleaned = true; + this.deps.setAdmissionActive?.(false); + this.release(entry); + // A replacement controller is safe only after the physical transition + // callback has settled. A scheduler transition timeout rejects its + // public promise early but deliberately retains the seal and callback. + if (registeredController === this.owner) registeredController = null; + if (this.owner) controllerSessions.delete(this.owner); + }; + const work = (async () => { // JOIN an in-flight open/disable rather than clobbering it. Overwriting // `this.transition` left the other transition running concurrently, so a @@ -482,7 +980,7 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // // Failures are absorbed: the other transition losing to a teardown is an // expected outcome, and shutdown must reach terminal regardless. - const inFlight = this.transition?.work; + const inFlight = superseded && this.transitionSettlement(superseded); if (inFlight) await inFlight.catch(() => undefined); // A `if (this.readState() === 'shutdown') return;` used to sit here. Under // the latch it is ALWAYS true, so keeping it would skip every teardown — @@ -503,23 +1001,60 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // // The error propagates: a shutdown that could not quiesce the store is // not a clean one and the caller has to be able to tell. - await this.deps.barrier('system-record.shutdown', async () => { - await this.deps.handoff.destroyClient(); - await this.deps.handoff.stopAndProveOwnedChildDead(); - await this.deps.handoff.awaitRetiredWork(); - }); + // A recovery that shutdown superseded may already have performed the + // one physical stop/drain. Joining it is sufficient; repeating the + // process-tree signal and port proof is not an idempotency assumption + // this lifecycle is allowed to make. + const recoveryAlreadySettled = + supersededRecovery?.physicalSettlement.state === 'all-generations-dead'; + if (recoveryAlreadySettled) { + entry.physicalSucceeded = true; + } else { + await this.deps.barrier('system-record.shutdown', () => { + // Keep the callback promise separately from the barrier's public + // promise. The scheduler may reject the latter at its transition + // timeout while deliberately leaving this callback and its seal + // alive until the physical teardown settles. + const physicalWork = (async () => { + try { + // An apply that was active when shutdown sealed admission + // attaches its uncertainty to this already-enqueued transition. + // Shutdown proves physical settlement but deliberately never + // starts a replacement child or performs a post-read. + if (entry.recovery) { + const recovered = await this.recoverInsideBarrier(entry.recovery, 'shutdown'); + if (!recovered.physicallySettled) { + throw new Error( + 'system-record shutdown could not prove uncertain write settled', + ); + } + } else { + await this.deps.handoff.destroyClient(); + await this.deps.handoff.stopAndProveOwnedChildDead(); + await this.deps.handoff.awaitRetiredWork(); + } + entry.physicalSucceeded = true; + } finally { + entry.physicalSettled = true; + } + })(); + entry.physicalWork = physicalWork; + return physicalWork; + }); + } + } catch (error) { + this.failManagedMutationsClosed('shutdown transition did not physically settle'); + throw error; } finally { - this.descriptor = null; // `this.current = 'shutdown'` used to be here. The latch already // committed it and `commitState` refuses to move off it, so a second // write would be a redundant mechanism for the same fact. - this.transition = null; - // Release the process-global registration. Holding it past shutdown - // made a replacement controller unconstructable for the process - // lifetime — and because the adapter calls the factory inside an - // unguarded capability probe, the resulting throw escaped a - // `getSystemRecordLaneControllerV1?.()` call, which must never throw. - if (registeredController === this.owner) registeredController = null; + entry.reportedSettled = true; + // Neither timeout is physical proof. A wait-phase timeout never invoked + // the callback; a transition timeout leaves it running under the + // scheduler's exclusive seal. Retain ownership in both cases unless a + // callback actually completed the teardown. + if (entry.physicalWork === null || entry.physicalSettled) cleanup(); } })(); @@ -534,11 +1069,22 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { // It also makes the shutdown visible to `open()` and `close('disable')`, // which join `this.transition.work` and then re-check terminal state. // - // A separate `shutdownWork` field was tried here and removed: with this - // assignment synchronous it never changed an outcome, and mutating it away - // left all 36 tests green. An inert guard reads as protection, so it is - // worse than none. - this.transition = { kind: 'shutdown', descriptor: null, work }; + entry.work = work; + entry.settlement = (async () => { + try { + await work; + } catch (reportedError) { + // Once a scheduler callback has started, its promise is the physical + // truth. Later shutdown callers join it rather than replaying the + // already-reported barrier timeout. + if (entry.physicalWork) await entry.physicalWork; + else throw reportedError; + } + })().finally(cleanup); + // The retained settlement may reject after the first caller has already + // handled the public timeout. Observe it here without changing the promise + // later callers receive. + void entry.settlement.catch(() => undefined); return work; } @@ -552,21 +1098,74 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { * notification, so a session that was enabled a millisecond ago may already * be writing into a different process. */ - async applyVerified(proof: unknown): Promise { + async applyVerifiedForBinding( + proof: unknown, + facade: SystemRecordLaneFacadeBindingV1, + ): Promise { if (this.current === 'shutdown' || this.current === 'unavailable') { - return { outcome: 'capability-lost' }; + return this.refuseVerifiedBeforeDispatch(proof, { outcome: 'capability-lost' }); } if (this.current !== 'enabled') { // enabling / disabling / reconciling are all "not admitting work now". - return { outcome: 'deferred', reason: 'generation-changed' }; + return this.refuseVerifiedBeforeDispatch(proof, { + outcome: 'deferred', + reason: 'generation-changed', + }); + } + + // A facade names exactly one activation. Descriptor equality alone is not + // sufficient: disabling and reopening the same descriptor creates a new + // physical child/epoch, and an old facade must not quietly inherit it. + if ( + facade.descriptor !== this.descriptor || + facade.activationGeneration !== this.activation.toString(10) || + facade.sessionIdentity !== this.activeSessionIdentity || + facade.childGeneration !== this.activeChildGeneration || + facade.materializationEpoch !== this.activeMaterializationEpoch + ) { + return this.refuseVerifiedBeforeDispatch(proof, { + outcome: 'deferred', + reason: 'generation-changed', + }); } const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.deps.lease); - if (!snapshot || snapshot.terminal) return { outcome: 'capability-lost' }; - if (!snapshot.ready) return { outcome: 'deferred', reason: 'generation-changed' }; + if (!snapshot || snapshot.terminal) { + return this.refuseVerifiedBeforeDispatch(proof, { outcome: 'capability-lost' }); + } + if (!snapshot.ready || snapshot.childGeneration !== facade.childGeneration) { + return this.refuseVerifiedBeforeDispatch(proof, { + outcome: 'deferred', + reason: 'generation-changed', + }); + } const boundGeneration = snapshot.childGeneration; - const result = await this.deps.executor.applyVerified(proof, boundGeneration); + const executionBinding: SystemRecordLaneExecutionBindingV1 = Object.freeze({ + activationGeneration: facade.activationGeneration, + networkId: facade.networkId, + kind: facade.kind, + mode: facade.mode, + sessionIdentity: facade.sessionIdentity, + childGeneration: boundGeneration, + materializationEpoch: facade.materializationEpoch, + }); + if (this.deps.executor.applyVerifiedSettlementBound) { + const settlement = await this.deps.executor.applyVerifiedSettlementBound( + proof, + executionBinding, + this.registerRecovery, + ); + // The internal carrier is authoritative. In particular, a public + // `root-collision` may eventually include a settled quarantine mutation, + // while an `applied` result has already been exact-postread. Neither fact + // may be reconstructed from the public outcome spelling here. + return settlement.outcome; + } + + const result = this.deps.executor.applyVerifiedBound + ? await this.deps.executor.applyVerifiedBound(proof, executionBinding) + : await this.deps.executor.applyVerified(proof, boundGeneration); // Derive the FINAL outcome first, then seal on it. // @@ -586,8 +1185,18 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { after !== null && !after.terminal && after.ready && - after.childGeneration === boundGeneration; + after.childGeneration === boundGeneration && + this.current === 'enabled' && + this.descriptor === facade.descriptor && + this.activation.toString(10) === facade.activationGeneration && + this.activeSessionIdentity === facade.sessionIdentity && + this.activeChildGeneration === facade.childGeneration && + this.activeMaterializationEpoch === facade.materializationEpoch; + // Compatibility-only B2 fallback. Production B3 composition uses the + // explicit settlement carrier above; an older injected executor can only + // mutate on these two success outcomes. Do not extend this inference to a + // future mutating outcome such as quarantine. const final: SystemRecordApplyOutcomeV1 = !attributable && (result.outcome === 'applied' || result.outcome === 'already-applied') ? { @@ -608,6 +1217,409 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { return final; } + private refuseVerifiedBeforeDispatch( + proof: unknown, + outcome: SystemRecordApplyOutcomeV1, + ): SystemRecordApplyOutcomeV1 { + try { + this.deps.executor.discardVerified?.(proof); + } catch { + // Discard is a reservation-release hook, not part of lifecycle outcome + // selection. A broken implementation must not turn a fail-closed refusal + // into an exception or admit the proof to an executor path. + } + return outcome; + } + + /** + * Transfer one ambiguous write to lifecycle recovery. + * + * This is an arrow property deliberately: the executor receives it as a + * capability and invokes it synchronously from inside its exclusive permit. + * Calling `barrier` below synchronously installs the scheduler seal before + * this method returns, even though the transition itself cannot begin until + * that permit drains. + */ + private readonly registerRecovery: SystemRecordAtomicRecoveryRegistrarV1 = (request) => { + this.assertRecoveryRequestBound(request); + this.recoverySequence += 1n; + + let resolve!: (resolution: SystemRecordAtomicRecoveryResolutionV1) => void; + const completion = new Promise((settle) => { + resolve = settle; + }); + const recovery: SystemRecordPendingRecoveryV1 = { + request, + recoveryGeneration: this.recoverySequence.toString(10), + absoluteDeadlineMs: performance.now() + SYSTEM_RECORD_RECOVERY_DEADLINE_MS_V1, + completion, + resolve, + exactReadAbort: new AbortController(), + physicalSettlement: { state: 'unsettled' }, + settled: false, + }; + + const active = this.transition; + if (active?.kind === 'disable' || active?.kind === 'shutdown') { + if (active.recovery !== null) { + throw new Error('system-record lifecycle already owns an uncertain write'); + } + active.recovery = recovery; + } else { + if (active !== null) { + throw new Error(`system-record ${active.kind} transition cannot accept recovery ownership`); + } + if (!this.commitState('reconciling')) { + throw new Error('system-record terminal lane cannot enqueue recovery'); + } + const entry: Extract = { + kind: 'recovery', + descriptor: null, + recovery, + shutdownTeardownComplete: false, + work: Promise.resolve(), + physicalWork: null, + physicalSettled: false, + }; + this.transition = entry; + entry.work = this.runRecovery(entry); + // Recovery is intentionally detached from the original sync call after + // ownership transfer, but never allowed to reject unobserved. + void entry.work.finally(() => { + if (entry.recovery.settled) this.release(entry); + }).catch(() => undefined); + } + + return Object.freeze({ + ownership: request.ownership, + recoveryGeneration: recovery.recoveryGeneration, + completion, + }); + }; + + private async runRecovery( + entry: Extract, + ): Promise { + try { + await this.deps.barrier('system-record.recovery', () => { + const physicalWork = (async () => { + try { + const result = await this.recoverInsideBarrier(entry.recovery, 'resume'); + entry.shutdownTeardownComplete = result.shutdownTeardownComplete; + if (result.resolution === 'unavailable') { + this.failManagedMutationsClosed('uncertain write recovery did not settle exactly'); + if (this.readState() !== 'shutdown') { + this.commitState('unavailable'); + this.descriptor = null; + this.clearActiveBinding(); + } + return; + } + + // Disable/shutdown intent may latch under any await above. It owns the + // tail and must never be overwritten by a recovery continuation. + if (this.readState() !== 'reconciling') return; + // Publish the replacement binding BEFORE this barrier releases its + // scheduler seal. No await separates these writes, so no admission can + // observe a proven replacement listener with the retired binding. + // Epoch/activation stay unchanged because exact settlement was read at + // that epoch; a fresh identity + child makes old facades stale. + this.activeSessionIdentity = Object.freeze(Object.create(null) as object); + this.activeChildGeneration = result.childGeneration; + this.activeFacade = null; + this.commitState('enabled'); + } finally { + entry.physicalSettled = true; + } + })(); + entry.physicalWork = physicalWork; + return physicalWork; + }); + } catch { + // A transition timeout reports before the scheduler callback settles. + // Retain its ownership and the executor's proof reservation until that + // callback reaches a physical result. A callback that ignores abort can + // therefore keep this promise pending until process exit by design. + if (entry.physicalWork) { + try { + await entry.physicalWork; + return; + } catch { + // Fall through only after the physical callback itself rejected. + } + } + this.failManagedMutationsClosed('uncertain write recovery transition failed'); + if (entry.recovery.physicalSettlement.state === 'all-generations-dead') { + this.settleRecovery(entry.recovery, Object.freeze({ resolution: 'unavailable' })); + } + if (this.readState() !== 'shutdown') { + this.commitState('unavailable'); + this.descriptor = null; + this.clearActiveBinding(); + } + } + } + + private async recoverInsideBarrier( + recovery: SystemRecordPendingRecoveryV1, + intent: 'resume' | 'disable' | 'shutdown', + ): Promise<{ + readonly resolution: SystemRecordAtomicRecoveryResolutionV1['resolution']; + readonly childGeneration: string; + readonly physicallySettled: boolean; + readonly shutdownTeardownComplete: boolean; + }> { + let childGeneration = recovery.request.binding.childGeneration; + try { + // The old request has already left its scheduler permit. Prove its child + // dead first, then destroy/drain the generation-owned client and every + // retained promise before a replacement listener can bind the port. + await this.retireRecoveryGeneration(recovery); + + if (intent === 'shutdown' || this.readState() === 'shutdown') { + recovery.physicalSettlement.state = 'all-generations-dead'; + const unavailable = Object.freeze({ resolution: 'unavailable' as const }); + this.settleRecovery(recovery, unavailable); + return { + resolution: 'unavailable', + childGeneration, + physicallySettled: true, + shutdownTeardownComplete: true, + }; + } + + this.assertRecoveryDeadline(recovery, 'replacement start'); + // Starting is a physical side effect before its promise can settle. Mark + // it conservatively live first so a synchronous spawn error, readiness + // timeout, or ownership-proof failure still drives stop/prove cleanup. + recovery.physicalSettlement.state = 'replacement-live'; + await this.deps.handoff.startAndProveCleanGeneration(recovery.absoluteDeadlineMs); + const owned = readManagedOxigraphOwnershipSnapshotV1(this.deps.lease); + if (!owned || owned.terminal || !owned.ready) { + throw new Error('system-record recovery did not bind a proven-ready child'); + } + childGeneration = owned.childGeneration; + const createRuntime = this.deps.handoff.createRecoveryRuntime; + if (!createRuntime) { + throw new Error('system-record exact recovery runtime is unavailable'); + } + const binding = Object.freeze({ + ...recovery.request.binding, + childGeneration, + }); + const supplied = createRuntime( + binding, + recovery.absoluteDeadlineMs, + recovery.exactReadAbort.signal, + ); + if ( + supplied.client.childGeneration !== childGeneration || + supplied.absoluteDeadlineMs !== recovery.absoluteDeadlineMs || + supplied.signal !== recovery.exactReadAbort.signal + ) { + throw new Error( + 'system-record recovery runtime is bound to the wrong generation/deadline/signal', + ); + } + const runtime: SystemRecordAtomicRecoveryRuntimeV1 = Object.freeze({ + ...supplied, + assertAttributable: () => { + const current = readManagedOxigraphOwnershipSnapshotV1(this.deps.lease); + return ( + this.readState() !== 'shutdown' && + current !== null && + !current.terminal && + current.ready && + current.childGeneration === childGeneration && + supplied.assertAttributable() + ); + }, + }); + this.assertRecoveryDeadline(recovery, 'exact reconciliation'); + const resolution = await recovery.request.reconcile(runtime); + if (this.readState() === 'shutdown' || resolution.resolution === 'unavailable') { + // A replacement was made externally reachable, so an unsuccessful + // exact read cannot merely mark the lane unavailable and return. Reap + // that replacement under the SAME deadline before the barrier seal is + // released; otherwise ordinary writes can hit a child whose reserved + // state has not been classified as prior or next. + await this.settleRecoveryPhysicalDead(recovery); + const unavailable = Object.freeze({ resolution: 'unavailable' as const }); + this.settleRecovery(recovery, unavailable); + return { + resolution: 'unavailable', + childGeneration, + physicallySettled: true, + shutdownTeardownComplete: this.readState() === 'shutdown', + }; + } + this.settleRecovery(recovery, resolution); + return { + resolution: resolution.resolution, + childGeneration, + physicallySettled: true, + shutdownTeardownComplete: false, + }; + } catch { + recovery.exactReadAbort.abort(new Error('system-record exact recovery failed')); + try { + await this.settleRecoveryPhysicalDead(recovery); + } catch { + this.failManagedMutationsClosed('recovery child could not be proven dead'); + } + const unavailable = Object.freeze({ resolution: 'unavailable' as const }); + // An executor may release its charged proof only after exact attribution + // or proof that every possibly-addressed generation is dead. If teardown + // itself fails, leave completion pending and the charge terminally owned + // until process exit. + if (recovery.physicalSettlement.state === 'all-generations-dead') { + this.settleRecovery(recovery, unavailable); + } + return { + resolution: 'unavailable', + childGeneration, + physicallySettled: + recovery.physicalSettlement.state === 'all-generations-dead', + shutdownTeardownComplete: + this.readState() === 'shutdown' && + recovery.physicalSettlement.state === 'all-generations-dead', + }; + } + } + + private async retireRecoveryGeneration( + recovery: SystemRecordPendingRecoveryV1, + terminalCleanup = false, + ): Promise { + const deadline = terminalCleanup ? undefined : recovery.absoluteDeadlineMs; + if (recovery.physicalSettlement.state === 'unsettled' + || recovery.physicalSettlement.state === 'replacement-live') { + if (!terminalCleanup) this.assertRecoveryDeadline(recovery, 'owned child stop'); + await this.deps.handoff.stopAndProveOwnedChildDead(deadline); + recovery.physicalSettlement.state = 'owned-child-dead'; + } + if (recovery.physicalSettlement.state === 'owned-child-dead') { + if (!terminalCleanup) this.assertRecoveryDeadline(recovery, 'retired client destroy'); + await this.deps.handoff.destroyClient(deadline); + if (!terminalCleanup) this.assertRecoveryDeadline(recovery, 'retired work drain'); + await this.deps.handoff.awaitRetiredWork(deadline); + recovery.physicalSettlement.state = 'old-generation-dead'; + } + } + + private async settleRecoveryPhysicalDead( + recovery: SystemRecordPendingRecoveryV1, + ): Promise { + if (recovery.physicalSettlement.state === 'all-generations-dead') return; + // The operational deadline may already have expired while an attributable + // exact read was in flight. Terminal teardown is safety work, not a retry: + // retain the barrier and run it to physical settlement without passing an + // already-expired deadline that would skip the stop/drain proof. + await this.retireRecoveryGeneration(recovery, true); + if (recovery.physicalSettlement.state !== 'old-generation-dead') { + throw new Error('system-record recovery could not prove every child generation dead'); + } + recovery.physicalSettlement.state = 'all-generations-dead'; + } + + private assertRecoveryDeadline( + recovery: SystemRecordPendingRecoveryV1, + phase: string, + ): void { + if (performance.now() >= recovery.absoluteDeadlineMs) { + throw new Error(`system-record recovery deadline expired before ${phase}`); + } + } + + private recoveryOf( + transition: SystemRecordLaneTransitionV1 | null, + ): SystemRecordPendingRecoveryV1 | null { + if (transition === null || transition.kind === 'open') return null; + return transition.recovery; + } + + private transitionSettlement(transition: SystemRecordLaneTransitionV1): Promise { + return transition.kind === 'disable' || transition.kind === 'shutdown' + ? transition.settlement + : transition.work; + } + + private failManagedMutationsClosed(reason: string): void { + try { + this.deps.handoff.failManagedMutationsClosed?.(reason); + } catch { + // The callback is specified as a synchronous latch. A broken adapter must + // not replace the original lifecycle failure or make the lane look less + // terminal; `unavailable`/`shutdown` still deny the structured path. + } + } + + private settleRecovery( + recovery: SystemRecordPendingRecoveryV1, + resolution: SystemRecordAtomicRecoveryResolutionV1, + ): void { + if (recovery.settled) return; + recovery.settled = true; + recovery.resolve(Object.freeze(resolution)); + } + + private assertRecoveryRequestBound(request: SystemRecordAtomicRecoveryRequestV1): void { + const binding = request.binding; + if ( + request === null || + typeof request !== 'object' || + request.ownership === null || + typeof request.ownership !== 'object' || + typeof request.reconcile !== 'function' || + binding.activationGeneration !== this.activation.toString(10) || + binding.networkId !== this.activeNetworkId || + binding.kind !== 'agents' || + this.descriptor !== `${binding.networkId}|${binding.kind}|${binding.mode}` || + binding.sessionIdentity !== this.activeSessionIdentity || + binding.childGeneration !== this.activeChildGeneration || + binding.materializationEpoch !== this.activeMaterializationEpoch + ) { + throw new Error('system-record recovery request is not bound to the active lane'); + } + } + + private createFacade( + descriptor: string, + activation: Readonly<{ + networkId: string; + kind: 'agents'; + mode: 'shadow' | 'authoritative'; + }>, + ): SystemRecordLaneSessionV1 { + if ( + this.current !== 'enabled' || + this.descriptor !== descriptor || + this.activeSessionIdentity === null || + this.activeChildGeneration === null || + this.activeMaterializationEpoch === null + ) { + throw new Error('system-record lane activation changed before facade publication'); + } + if (this.activeFacade !== null) return this.activeFacade; + this.activeFacade = new SystemRecordLaneFacade(this, Object.freeze({ + descriptor, + activationGeneration: this.activation.toString(10), + ...activation, + sessionIdentity: this.activeSessionIdentity, + childGeneration: this.activeChildGeneration, + materializationEpoch: this.activeMaterializationEpoch, + })); + return this.activeFacade; + } + + private clearActiveBinding(): void { + this.activeNetworkId = null; + this.activeSessionIdentity = null; + this.activeChildGeneration = null; + this.activeMaterializationEpoch = null; + this.activeFacade = null; + } + /** * Clear the in-flight pointer ONLY if it is still ours. * @@ -649,6 +1661,10 @@ class SystemRecordLaneSession implements SystemRecordLaneSessionV1 { private commitState(next: SystemRecordLaneStateV1): boolean { if (this.readState() === 'shutdown') return false; this.current = next; + if (next === 'enabling') this.deps.setAdmissionActive?.(true); + if (next === 'disabled' || next === 'unavailable') { + this.deps.setAdmissionActive?.(false); + } return true; } diff --git a/packages/storage/src/system-record-next-state-v1-internal.ts b/packages/storage/src/system-record-next-state-v1-internal.ts new file mode 100644 index 0000000000..0a90ed23fd --- /dev/null +++ b/packages/storage/src/system-record-next-state-v1-internal.ts @@ -0,0 +1,882 @@ +import { types as utilTypes } from 'node:util'; + +import { + assertCanonicalDigest, + isSafeIri, + MAX_DECIMAL_U64, + parseCanonicalDecimalU64, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import { + assertAgentProfileHeadObjectV1, + assertAgentProfileVerifiedAuthoritySummaryV1, + canonicalizeOwnedSubjectTableObjectV1, + canonicalizeSystemRecordAppliedStateV1, + canonicalizeSystemRecordCapacityStateV1, + canonicalizeSystemRecordMaterializationReceiptV1, + canonicalizeSystemRecordRootClaimSetV1, + computeAgentProfileHeadObjectDigestV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordAccountedBytesV1, + computeSystemRecordAppliedStateDigestV1, + computeSystemRecordMaterializationReceiptDigestV1, + computeSystemRecordRootClaimSetDigestV1, + computeSystemRecordStableKeyHashV1, + parseCanonicalOwnedSubjectTableObjectV1, + parseCanonicalSystemRecordAppliedStateV1, + parseCanonicalSystemRecordCapacityStateV1, + parseCanonicalSystemRecordMaterializationReceiptV1, + parseCanonicalSystemRecordRootClaimSetV1, + SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES, + SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_QUADS, + SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES, + SYSTEM_RECORD_MAX_INVENTORY_RECORDS, + SYSTEM_RECORD_MAX_OWNED_SUBJECTS, + type AgentProfileAppliedTransitionV1, + type OwnedSubjectTableObjectV1, + type SystemRecordAppliedStatePresentV1, + type SystemRecordCapacityStateV1, + type SystemRecordMaterializationReceiptV1, + type SystemRecordRootClaimSetV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import type { Quad } from './triple-store.js'; +import { + buildSystemRecordReservedStateQuadsV1, + SYSTEM_RECORD_V1_PREDICATES, + systemRecordProjectionGraphV1, + systemRecordRecordSubjectV1, + systemRecordRootClaimSubjectV1, +} from './system-record-rdf-schema-v1-internal.js'; +import { + assertAuthenticSystemRecordAppliedSnapshotV1, + assertSystemRecordRootClaimSnapshotV1, + type SystemRecordAppliedSnapshotV1, +} from './system-record-state-snapshot-v1-internal.js'; +import { + assertAuthenticSystemRecordVerifiedReplacementFactsV1, + type SystemRecordVerifiedReplacementFactsV1, +} from './system-record-verified-replacement-v1-internal.js'; + +export type SystemRecordActiveDerivationDeferredReasonV1 = + | 'non-active-state' + | 'authority-fork' + | 'authority-history-mismatch' + | 'verified-state-mismatch' + | 'root-state-changed'; + +export type SystemRecordActiveDerivationCapacityReasonV1 = + | 'state-revision-overflow' + | 'capacity-revision-overflow' + | 'record-count-cap' + | 'aggregate-cap' + | 'subject-union-cap'; + +export interface SystemRecordPriorMaterializationV1 { + readonly appliedState: SystemRecordAppliedSnapshotV1['appliedState']; + readonly headVersion?: string; + readonly capacityState: SystemRecordCapacityStateV1; + readonly materializationEpoch: string; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly rootClaimSet?: SystemRecordRootClaimSetV1; + readonly receipt?: SystemRecordMaterializationReceiptV1; + readonly rootClaimQuads: readonly Readonly[]; + readonly reservedQuads: readonly Readonly[]; + readonly requiredAbsentReservedSubjects: readonly string[]; +} + +export interface SystemRecordNextMaterializationV1 { + readonly appliedState: SystemRecordAppliedStatePresentV1; + readonly headVersion: string; + readonly appliedStateDigest: Digest32V1; + readonly capacityState: SystemRecordCapacityStateV1; + readonly materializationEpoch: string; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly rootClaimSet: SystemRecordRootClaimSetV1; + readonly receipt: SystemRecordMaterializationReceiptV1; + readonly receiptDigest: Digest32V1; + readonly rootClaimQuads: readonly Readonly[]; + readonly reservedQuads: readonly Readonly[]; + readonly projectionQuads: readonly Readonly[]; +} + +export interface SystemRecordRootClaimGuardV1 { + readonly claimSubject: string; + readonly recordSubject: string; +} + +export interface SystemRecordCompleteConditionalApplyV1 { + readonly projectionGraph: string; + readonly priorSubjects: readonly string[]; + readonly nextSubjects: readonly string[]; + readonly previousReservedQuads: readonly Readonly[]; + readonly requiredAbsentReservedSubjects: readonly string[]; + readonly nextReservedQuads: readonly Readonly[]; + readonly nextProjectionQuads: readonly Readonly[]; + readonly rootClaimGuards: readonly SystemRecordRootClaimGuardV1[]; +} + +export interface SystemRecordCompletePostReadExpectationV1 { + readonly reservedQuads: readonly Readonly[]; + readonly projectionSubjects: readonly string[]; + readonly projectionQuads: readonly Readonly[]; + readonly receipt: SystemRecordMaterializationReceiptV1; + readonly success: Readonly<{ + readonly stateRevision: string; + readonly appliedStateDigest: Digest32V1; + }>; +} + +export interface SystemRecordActiveReplacementCompleteV1< + Outcome extends 'ready' | 'already-applied' = 'ready' | 'already-applied', +> { + readonly outcome: Outcome; + readonly stableKeyHash: Digest32V1; + readonly projectionGraph: string; + readonly priorSubjects: readonly string[]; + readonly nextSubjects: readonly string[]; + readonly previousReservedQuads: readonly Readonly[]; + readonly requiredAbsentReservedSubjects: readonly string[]; + readonly nextReservedQuads: readonly Readonly[]; + readonly nextProjectionQuads: readonly Readonly[]; + readonly nextAppliedState: SystemRecordAppliedStatePresentV1; + readonly conditionalApply: SystemRecordCompleteConditionalApplyV1; + readonly postReadExpectation: SystemRecordCompletePostReadExpectationV1; + readonly prior: SystemRecordPriorMaterializationV1; + readonly next: SystemRecordNextMaterializationV1; + readonly rootClaimGuards: readonly SystemRecordRootClaimGuardV1[]; + readonly success: Readonly<{ + readonly stateRevision: string; + readonly appliedStateDigest: Digest32V1; + }>; +} + +export type SystemRecordActiveReplacementReadyV1 = + SystemRecordActiveReplacementCompleteV1<'ready'>; +export type SystemRecordActiveReplacementAlreadyAppliedV1 = + SystemRecordActiveReplacementCompleteV1<'already-applied'>; + +const AUTHENTIC_COMPLETE_DERIVATIONS = new WeakSet(); + +export type SystemRecordActiveReplacementDerivationV1 = + | SystemRecordActiveReplacementReadyV1 + | SystemRecordActiveReplacementAlreadyAppliedV1 + | Readonly<{ readonly outcome: 'stale' }> + | Readonly<{ + readonly outcome: 'deferred'; + readonly reason: SystemRecordActiveDerivationDeferredReasonV1; + }> + | Readonly<{ + readonly outcome: 'root-collision'; + readonly claimSubjects: readonly string[]; + }> + | Readonly<{ + readonly outcome: 'capacity-exhausted'; + readonly reason: SystemRecordActiveDerivationCapacityReasonV1; + }>; + +/** + * Pure active-only state transition. Its `ready` variant is complete: callers + * cannot fill in graphs, CAS rows, capacity values, roots, or receipt fields. + */ +export function deriveSystemRecordActiveReplacementV1(input: { + readonly facts: SystemRecordVerifiedReplacementFactsV1; + readonly snapshot: SystemRecordAppliedSnapshotV1; + readonly observedRootClaimQuads: readonly Readonly[]; +}): SystemRecordActiveReplacementDerivationV1 { + const { facts, snapshot } = input; + assertAuthenticSystemRecordAppliedSnapshotV1(snapshot); + assertTrustedReplacement(facts, snapshot); + const head = facts.head; + const summary = facts.verifiedAuthoritySummary; + const headDigest = computeAgentProfileHeadObjectDigestV1(head); + const stableKeyHash = computeSystemRecordStableKeyHashV1(head.networkId, head.peerId); + const candidateVersion = parseCanonicalDecimalU64(head.version); + + const authority = classifyAuthorityAdvance(snapshot, facts, headDigest); + if (authority.outcome !== 'advance') return authority; + + const rootClaimSet = canonicalRootClaimSet({ + objectType: 'system-record-root-claim-set', + kind: 'agents', + networkId: head.networkId, + stableKeyHash, + currentRoot: head.rootSubject, + historicalRoots: summary.historicalRoots, + }); + const recordSubject = systemRecordRecordSubjectV1(head.networkId, stableKeyHash); + const priorRootQuads = snapshot.state === 'present' + ? snapshot.expectedRootClaimQuads + : Object.freeze([]); + const priorRootSubjects = new Set(priorRootQuads.map((quad) => quad.subject)); + const nextRootSubjects = [rootClaimSet.currentRoot, ...rootClaimSet.historicalRoots] + .map((root) => systemRecordRootClaimSubjectV1(head.networkId, root)); + const requiredAbsentRootSubjects = canonicalSubjects( + nextRootSubjects.filter((subject) => !priorRootSubjects.has(subject)), + ); + const rootSnapshot = classifyRootSnapshot( + input.observedRootClaimQuads, + priorRootQuads, + requiredAbsentRootSubjects, + facts.networkId, + recordSubject, + ); + if (rootSnapshot.outcome !== 'match') return rootSnapshot; + + const nextTable = parseCanonicalOwnedSubjectTableObjectV1( + head.rootSubject, + canonicalizeOwnedSubjectTableObjectV1(head.rootSubject, facts.ownedSubjectTable), + ); + const priorTable = snapshot.state === 'present' + ? snapshot.ownedSubjectTable + : Object.freeze([]) as OwnedSubjectTableObjectV1; + + if (authority.equalHead) { + if (snapshot.state !== 'present') { + throw new Error('equal system-record head cannot exist in absent state'); + } + if (!persistedStateMatchesVerifiedHead( + snapshot, + facts, + nextTable, + rootClaimSet, + headDigest, + )) { + return Object.freeze({ outcome: 'deferred', reason: 'verified-state-mismatch' }); + } + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(snapshot.appliedState); + const previousReservedQuads = Object.freeze([ + ...snapshot.previousReservedQuads, + ...priorRootQuads, + ]); + const requiredAbsentReservedSubjects = canonicalSubjects([ + ...snapshot.requiredAbsentReservedSubjects, + ...requiredAbsentRootSubjects, + ]); + const projectionGraph = systemRecordProjectionGraphV1(facts.mode); + const rootClaimGuards = rootGuards(nextRootSubjects, recordSubject); + const success = Object.freeze({ + stateRevision: snapshot.appliedState.stateRevision, + appliedStateDigest, + }); + const prior = Object.freeze({ + appliedState: snapshot.appliedState, + headVersion: snapshot.headVersion, + capacityState: snapshot.capacityState, + materializationEpoch: snapshot.materializationEpoch, + ownedSubjectTable: priorTable, + rootClaimSet: snapshot.rootClaimSet, + receipt: snapshot.receipt, + rootClaimQuads: priorRootQuads, + reservedQuads: previousReservedQuads, + requiredAbsentReservedSubjects, + }); + const next = Object.freeze({ + appliedState: snapshot.appliedState, + headVersion: snapshot.headVersion, + appliedStateDigest, + capacityState: snapshot.capacityState, + materializationEpoch: snapshot.materializationEpoch, + ownedSubjectTable: nextTable, + rootClaimSet: snapshot.rootClaimSet, + receipt: snapshot.receipt, + receiptDigest: computeSystemRecordMaterializationReceiptDigestV1(snapshot.receipt), + rootClaimQuads: priorRootQuads, + reservedQuads: previousReservedQuads, + projectionQuads: facts.projectionQuads, + }); + const conditionalApply = completeConditionalApply({ + projectionGraph, + priorSubjects: priorTable, + nextSubjects: nextTable, + previousReservedQuads, + requiredAbsentReservedSubjects, + nextReservedQuads: previousReservedQuads, + nextProjectionQuads: facts.projectionQuads, + rootClaimGuards, + }); + const postReadExpectation = Object.freeze({ + reservedQuads: previousReservedQuads, + projectionSubjects: nextTable, + projectionQuads: facts.projectionQuads, + receipt: snapshot.receipt, + success, + }); + return markComplete(Object.freeze({ + outcome: 'already-applied', + stableKeyHash, + projectionGraph, + priorSubjects: priorTable, + nextSubjects: nextTable, + previousReservedQuads, + requiredAbsentReservedSubjects, + nextReservedQuads: previousReservedQuads, + nextProjectionQuads: facts.projectionQuads, + nextAppliedState: snapshot.appliedState, + conditionalApply, + postReadExpectation, + prior, + next, + rootClaimGuards, + success, + })); + } + + if (mergedSubjectCount(priorTable, nextTable) > SYSTEM_RECORD_MAX_OWNED_SUBJECTS) { + return Object.freeze({ outcome: 'capacity-exhausted', reason: 'subject-union-cap' }); + } + + const nextStateRevision = incrementU64( + snapshot.state === 'present' ? snapshot.appliedState.stateRevision : '0', + ); + if (nextStateRevision === null) { + return Object.freeze({ outcome: 'capacity-exhausted', reason: 'state-revision-overflow' }); + } + const capacityRevision = incrementU64(snapshot.capacityState.revision); + if (capacityRevision === null) { + return Object.freeze({ outcome: 'capacity-exhausted', reason: 'capacity-revision-overflow' }); + } + + const tableBytes = canonicalizeOwnedSubjectTableObjectV1(head.rootSubject, nextTable).byteLength; + const rootClaimSetDigest = computeSystemRecordRootClaimSetDigestV1(rootClaimSet); + const appliedState = canonicalAppliedState({ + objectType: 'system-record-applied-state', + state: 'present', + kind: 'agents', + networkId: head.networkId, + stableKeyHash, + peerId: head.peerId, + stateRevision: nextStateRevision, + status: 'active', + headDigest, + transitionLineage: summary.transitionLineage, + projectionDigest: facts.projectionDigest, + projectionBytes: head.projectionBytes, + projectionQuads: head.projectionQuads, + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1(head.rootSubject, nextTable), + ownedSubjectCount: nextTable.length.toString(), + ownedSubjectTableBytes: tableBytes.toString(), + currentRoot: head.rootSubject, + historicalRoots: summary.historicalRoots, + conflictDigestSlots: snapshot.state === 'present' + ? snapshot.appliedState.conflictDigestSlots + : Object.freeze([]), + conflictOverflow: snapshot.state === 'present' + ? snapshot.appliedState.conflictOverflow + : false, + materializationEpoch: facts.materializationEpoch, + rootClaimSetDigest, + accountedBytes: computeSystemRecordAccountedBytesV1( + tableBytes, + Number(parseCanonicalDecimalU64(head.projectionBytes)), + ).toString(), + }); + const nextCapacity = deriveCapacity( + snapshot, + appliedState, + tableBytes, + capacityRevision, + ); + if (nextCapacity.outcome !== 'capacity') return nextCapacity; + + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState); + const receipt = canonicalReceipt({ + objectType: 'system-record-materialization-receipt', + kind: 'agents', + networkId: head.networkId, + stableKeyHash, + stateRevision: nextStateRevision, + appliedStateDigest, + headDigest, + materializationEpoch: facts.materializationEpoch, + }); + const nextReserved = buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: candidateVersion.toString(), + ownedSubjectTable: nextTable, + rootClaimSet, + capacityState: nextCapacity.state, + receipt, + }); + const priorReservedQuads = Object.freeze([ + ...snapshot.previousReservedQuads, + ...priorRootQuads, + ]); + const nextReservedQuads = Object.freeze([ + ...nextReserved.record, + ...nextReserved.capacity, + ...nextReserved.epoch, + ...nextReserved.receipt, + ...nextReserved.rootClaims, + ]); + const requiredAbsentReservedSubjects = canonicalSubjects([ + ...snapshot.requiredAbsentReservedSubjects, + ...requiredAbsentRootSubjects, + ]); + const projectionGraph = systemRecordProjectionGraphV1(facts.mode); + const rootClaimGuards = rootGuards(nextRootSubjects, recordSubject); + const success = Object.freeze({ stateRevision: nextStateRevision, appliedStateDigest }); + const prior = Object.freeze({ + appliedState: snapshot.appliedState, + ...(snapshot.state === 'present' ? { headVersion: snapshot.headVersion } : {}), + capacityState: snapshot.capacityState, + materializationEpoch: snapshot.materializationEpoch, + ownedSubjectTable: priorTable, + ...(snapshot.state === 'present' ? { + rootClaimSet: snapshot.rootClaimSet, + receipt: snapshot.receipt, + } : {}), + rootClaimQuads: priorRootQuads, + reservedQuads: priorReservedQuads, + requiredAbsentReservedSubjects, + }); + const next = Object.freeze({ + appliedState, + headVersion: candidateVersion.toString(), + appliedStateDigest, + capacityState: nextCapacity.state, + materializationEpoch: facts.materializationEpoch, + ownedSubjectTable: nextTable, + rootClaimSet, + receipt, + receiptDigest: computeSystemRecordMaterializationReceiptDigestV1(receipt), + rootClaimQuads: nextReserved.rootClaims, + reservedQuads: nextReservedQuads, + projectionQuads: facts.projectionQuads, + }); + const conditionalApply = completeConditionalApply({ + projectionGraph, + priorSubjects: priorTable, + nextSubjects: nextTable, + previousReservedQuads: priorReservedQuads, + requiredAbsentReservedSubjects, + nextReservedQuads, + nextProjectionQuads: facts.projectionQuads, + rootClaimGuards, + }); + const postReadExpectation = Object.freeze({ + reservedQuads: nextReservedQuads, + projectionSubjects: nextTable, + projectionQuads: facts.projectionQuads, + receipt, + success, + }); + return markComplete(Object.freeze({ + outcome: 'ready', + stableKeyHash, + projectionGraph, + priorSubjects: priorTable, + nextSubjects: nextTable, + previousReservedQuads: priorReservedQuads, + requiredAbsentReservedSubjects, + nextReservedQuads, + nextProjectionQuads: facts.projectionQuads, + nextAppliedState: appliedState, + conditionalApply, + postReadExpectation, + prior, + next, + rootClaimGuards, + success, + })); +} + +/** Equal digest is necessary but not sufficient for an already-applied result. */ +function persistedStateMatchesVerifiedHead( + snapshot: Extract, + facts: SystemRecordVerifiedReplacementFactsV1, + table: OwnedSubjectTableObjectV1, + rootClaimSet: SystemRecordRootClaimSetV1, + headDigest: Digest32V1, +): boolean { + const state = snapshot.appliedState; + const tableBytes = canonicalizeOwnedSubjectTableObjectV1( + facts.head.rootSubject, + table, + ).byteLength; + const rootClaimSetDigest = computeSystemRecordRootClaimSetDigestV1(rootClaimSet); + return state.status === 'active' + && state.headDigest === headDigest + && String(state.transitionLineage.length) === facts.head.authoritySequence + && snapshot.headVersion === facts.head.version + && state.projectionDigest === facts.projectionDigest + && state.projectionBytes === facts.head.projectionBytes + && state.projectionQuads === facts.head.projectionQuads + && state.ownedSubjectTableDigest === facts.head.ownedSubjectTableDigest + && state.ownedSubjectCount === facts.head.ownedSubjectCount + && state.ownedSubjectTableBytes === String(tableBytes) + && state.currentRoot === facts.head.rootSubject + && state.rootClaimSetDigest === rootClaimSetDigest + && state.accountedBytes === String(computeSystemRecordAccountedBytesV1( + tableBytes, + Number(parseCanonicalDecimalU64(facts.head.projectionBytes)), + )); +} + +export function assertAuthenticSystemRecordActiveReplacementCompleteV1( + value: unknown, +): asserts value is SystemRecordActiveReplacementCompleteV1 { + if (value === null || typeof value !== 'object' || !AUTHENTIC_COMPLETE_DERIVATIONS.has(value)) { + throw new Error('system-record complete derivation was not produced by the verified state derivation'); + } +} + +function assertTrustedReplacement( + facts: SystemRecordVerifiedReplacementFactsV1, + snapshot: SystemRecordAppliedSnapshotV1, +): void { + assertAuthenticSystemRecordVerifiedReplacementFactsV1(facts); + assertAgentProfileHeadObjectV1(facts.head); + assertAgentProfileVerifiedAuthoritySummaryV1(facts.verifiedAuthoritySummary); + if (facts.head.state !== 'active' + || facts.kind !== 'agents' + || facts.networkId !== facts.head.networkId + || facts.materializationEpoch !== snapshot.materializationEpoch + || (facts.mode !== 'shadow' && facts.mode !== 'authoritative')) { + throw new Error('verified active replacement crosses its materialization binding'); + } + assertCanonicalDigest(facts.projectionDigest); + const summary = facts.verifiedAuthoritySummary; + const headDigest = computeAgentProfileHeadObjectDigestV1(facts.head); + if (summary.candidateHeadDigest !== headDigest + || BigInt(summary.transitionLineage.length) !== parseCanonicalDecimalU64(facts.head.authoritySequence) + || summary.historicalRoots.length !== summary.transitionLineage.length + || summary.tombstonePredecessor !== undefined + || summary.deletionTableDigest !== undefined) { + throw new Error('verified authority summary is incomplete or does not bind the active head'); + } + const lastTransition = summary.transitionLineage.at(-1); + if (facts.head.authoritySequence === '0') { + if (facts.head.acceptedTransitionDigest !== undefined + || summary.lastAuthorityTransitionPriorHeadDigest !== undefined) { + throw new Error('sequence-zero active replacement retained authority-transition state'); + } + } else if (lastTransition === undefined + || lastTransition.transitionDigest !== facts.head.acceptedTransitionDigest + || summary.lastAuthorityTransitionPriorHeadDigest === undefined) { + throw new Error('nonzero active replacement lacks its latest verified transition binding'); + } + const tableDigest = computeOwnedSubjectTableDigestV1( + facts.head.rootSubject, + facts.ownedSubjectTable, + ); + if (tableDigest !== facts.head.ownedSubjectTableDigest + || BigInt(facts.ownedSubjectTable.length) !== BigInt(facts.head.ownedSubjectCount) + || BigInt(facts.projectionQuads.length) !== BigInt(facts.head.projectionQuads)) { + throw new Error('verified active replacement facts no longer bind their head'); + } +} + +type AuthorityAdvance = + | Readonly<{ readonly outcome: 'advance'; readonly equalHead: boolean }> + | Exclude; + +function classifyAuthorityAdvance( + snapshot: SystemRecordAppliedSnapshotV1, + facts: SystemRecordVerifiedReplacementFactsV1, + headDigest: Digest32V1, +): AuthorityAdvance { + if (snapshot.state === 'absent') return Object.freeze({ outcome: 'advance', equalHead: false }); + const current = snapshot.appliedState; + if (current.status !== 'active') { + return Object.freeze({ outcome: 'deferred', reason: 'non-active-state' }); + } + const candidate = facts.head; + const candidateSequence = parseCanonicalDecimalU64(candidate.authoritySequence); + const currentSequence = BigInt(current.transitionLineage.length); + if (candidateSequence < currentSequence) return Object.freeze({ outcome: 'stale' }); + if (candidateSequence > currentSequence + 1n) { + return Object.freeze({ outcome: 'deferred', reason: 'authority-history-mismatch' }); + } + const summary = facts.verifiedAuthoritySummary; + const currentLineage = current.transitionLineage; + const currentHistory = current.historicalRoots; + if (candidateSequence === currentSequence) { + if (!sameTransitions(summary.transitionLineage, currentLineage) + || !sameStrings(summary.historicalRoots, currentHistory) + || candidate.rootSubject !== current.currentRoot) { + return Object.freeze({ outcome: 'deferred', reason: 'authority-history-mismatch' }); + } + const candidateVersion = parseCanonicalDecimalU64(candidate.version); + const currentVersion = parseCanonicalDecimalU64(snapshot.headVersion); + if (candidateVersion < currentVersion) return Object.freeze({ outcome: 'stale' }); + if (candidateVersion === currentVersion) { + return headDigest === current.headDigest + ? Object.freeze({ outcome: 'advance', equalHead: true }) + : Object.freeze({ outcome: 'deferred', reason: 'authority-fork' }); + } + return Object.freeze({ outcome: 'advance', equalHead: false }); + } + const expectedHistory = [...currentHistory, current.currentRoot]; + const tail = summary.transitionLineage.at(-1); + if (summary.transitionLineage.length !== currentLineage.length + 1 + || !sameTransitions(summary.transitionLineage.slice(0, -1), currentLineage) + || !sameStrings(summary.historicalRoots, expectedHistory) + || summary.lastAuthorityTransitionPriorHeadDigest !== current.headDigest + || tail === undefined + || tail.priorAuthoritySequence !== currentSequence.toString() + || tail.nextAuthoritySequence !== candidate.authoritySequence + || tail.transitionDigest !== candidate.acceptedTransitionDigest + || candidate.rootSubject === current.currentRoot + || currentHistory.includes(candidate.rootSubject)) { + return Object.freeze({ outcome: 'deferred', reason: 'authority-history-mismatch' }); + } + return Object.freeze({ outcome: 'advance', equalHead: false }); +} + +type RootSnapshotClassification = + | Readonly<{ readonly outcome: 'match' }> + | Extract; + +function classifyRootSnapshot( + actual: readonly Readonly[], + expected: readonly Readonly[], + requiredAbsent: readonly string[], + networkId: string, + contenderRecordSubject: string, +): RootSnapshotClassification { + try { + assertSystemRecordRootClaimSnapshotV1(actual, expected, requiredAbsent); + return Object.freeze({ outcome: 'match' }); + } catch { + const collided = exactForeignRootClaims( + actual, + new Set(requiredAbsent), + networkId, + contenderRecordSubject, + ); + return collided !== null && collided.length > 0 + ? Object.freeze({ outcome: 'root-collision', claimSubjects: collided }) + : Object.freeze({ outcome: 'deferred', reason: 'root-state-changed' }); + } +} + +/** A random/orphan row at a claim subject is corruption, not ownership evidence. */ +function exactForeignRootClaims( + quads: readonly Readonly[], + candidateSubjects: ReadonlySet, + networkId: string, + contenderRecordSubject: string, +): readonly string[] | null { + const bySubject = new Map[]>(); + for (const quad of quads) { + if (!candidateSubjects.has(quad.subject)) continue; + const rows = bySubject.get(quad.subject) ?? []; + rows.push(quad); + bySubject.set(quad.subject, rows); + } + const collisions: string[] = []; + for (const [subject, rows] of bySubject) { + if (rows.length !== 3) return null; + const byPredicate = new Map(rows.map((quad) => [quad.predicate, quad])); + if (byPredicate.size !== 3) return null; + const root = byPredicate.get(SYSTEM_RECORD_V1_PREDICATES.root); + const owner = byPredicate.get(SYSTEM_RECORD_V1_PREDICATES.claimedBy); + const position = byPredicate.get(SYSTEM_RECORD_V1_PREDICATES.claimPosition); + if (!root || !owner || !position + || !isSafeIri(root.object) + || systemRecordRootClaimSubjectV1(networkId, root.object) !== subject + || !isSafeIri(owner.object) + || owner.object === contenderRecordSubject + || !/^"(?:current|historical:(?:0|[1-9][0-9]*))"$/.test(position.object)) { + return null; + } + collisions.push(subject); + } + return Object.freeze(collisions.sort(compareUtf8)); +} + +type CapacityDerivation = + | Readonly<{ readonly outcome: 'capacity'; readonly state: SystemRecordCapacityStateV1 }> + | Extract; + +function deriveCapacity( + snapshot: SystemRecordAppliedSnapshotV1, + next: SystemRecordAppliedStatePresentV1, + nextTableBytes: number, + revision: string, +): CapacityDerivation { + const current = snapshot.capacityState; + const wasPresent = snapshot.state === 'present'; + const oldPendingTableBytes = wasPresent && 'pendingDeletionTableBytes' in snapshot.appliedState + ? BigInt(snapshot.appliedState.pendingDeletionTableBytes as string) + : 0n; + const prior = { + liveRecordCount: wasPresent ? 1n : 0n, + stateBytes: wasPresent ? BigInt(SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES) : 0n, + tableBytes: wasPresent + ? BigInt(snapshot.appliedState.ownedSubjectTableBytes) + oldPendingTableBytes + : 0n, + projectionBytes: wasPresent ? BigInt(snapshot.appliedState.projectionBytes) : 0n, + projectionQuads: wasPresent ? BigInt(snapshot.appliedState.projectionQuads) : 0n, + }; + const dimensions = { + liveRecordCount: replaceContribution(current.liveRecordCount, prior.liveRecordCount, 1n), + stateBytes: replaceContribution( + current.stateBytes, + prior.stateBytes, + BigInt(SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES), + ), + tableBytes: replaceContribution(current.tableBytes, prior.tableBytes, BigInt(nextTableBytes)), + projectionBytes: replaceContribution( + current.projectionBytes, + prior.projectionBytes, + BigInt(next.projectionBytes), + ), + projectionQuads: replaceContribution( + current.projectionQuads, + prior.projectionQuads, + BigInt(next.projectionQuads), + ), + }; + if (Object.values(dimensions).some((value) => value === null)) { + throw new Error('persisted system-record capacity underflows its current record contribution'); + } + const values = dimensions as Record; + if (values.liveRecordCount > BigInt(SYSTEM_RECORD_MAX_INVENTORY_RECORDS)) { + return Object.freeze({ outcome: 'capacity-exhausted', reason: 'record-count-cap' }); + } + if (values.stateBytes > BigInt(SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES) + || values.tableBytes > BigInt(SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES) + || values.projectionBytes > BigInt(SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES) + || values.stateBytes + values.tableBytes + values.projectionBytes + > BigInt(SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES) + || values.projectionQuads > BigInt(SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_QUADS)) { + return Object.freeze({ outcome: 'capacity-exhausted', reason: 'aggregate-cap' }); + } + const state = canonicalCapacity({ + objectType: 'system-record-capacity-state', + kind: 'agents', + networkId: next.networkId, + revision, + liveRecordCount: values.liveRecordCount.toString(), + stateBytes: values.stateBytes.toString(), + tableBytes: values.tableBytes.toString(), + projectionBytes: values.projectionBytes.toString(), + projectionQuads: values.projectionQuads.toString(), + }); + return Object.freeze({ outcome: 'capacity', state }); +} + +function replaceContribution(current: string, prior: bigint, next: bigint): bigint | null { + const value = BigInt(current); + return value < prior ? null : value - prior + next; +} + +function incrementU64(value: string): string | null { + const parsed = parseCanonicalDecimalU64(value); + return parsed === MAX_DECIMAL_U64 ? null : (parsed + 1n).toString(); +} + +function mergedSubjectCount(left: readonly string[], right: readonly string[]): number { + let leftIndex = 0; + let rightIndex = 0; + let count = 0; + while (leftIndex < left.length || rightIndex < right.length) { + const leftValue = left[leftIndex]; + const rightValue = right[rightIndex]; + if (leftValue === undefined) rightIndex += 1; + else if (rightValue === undefined) leftIndex += 1; + else { + const order = compareUtf8(leftValue, rightValue); + if (order <= 0) leftIndex += 1; + if (order >= 0) rightIndex += 1; + } + count += 1; + if (count > SYSTEM_RECORD_MAX_OWNED_SUBJECTS) return count; + } + return count; +} + +function canonicalAppliedState(value: unknown): SystemRecordAppliedStatePresentV1 { + const parsed = parseCanonicalSystemRecordAppliedStateV1( + canonicalizeSystemRecordAppliedStateV1(value as SystemRecordAppliedStatePresentV1), + ); + if (parsed.state !== 'present') throw new Error('derived active state became absent'); + return parsed; +} + +function canonicalRootClaimSet(value: unknown): SystemRecordRootClaimSetV1 { + return parseCanonicalSystemRecordRootClaimSetV1( + canonicalizeSystemRecordRootClaimSetV1(value as SystemRecordRootClaimSetV1), + ); +} + +function canonicalCapacity(value: unknown): SystemRecordCapacityStateV1 { + return parseCanonicalSystemRecordCapacityStateV1( + canonicalizeSystemRecordCapacityStateV1(value as SystemRecordCapacityStateV1), + ); +} + +function canonicalReceipt(value: unknown): SystemRecordMaterializationReceiptV1 { + return parseCanonicalSystemRecordMaterializationReceiptV1( + canonicalizeSystemRecordMaterializationReceiptV1(value as SystemRecordMaterializationReceiptV1), + ); +} + +function rootGuards( + claimSubjects: readonly string[], + recordSubject: string, +): readonly SystemRecordRootClaimGuardV1[] { + return Object.freeze(claimSubjects.map((claimSubject) => Object.freeze({ + claimSubject, + recordSubject, + }))); +} + +function completeConditionalApply( + value: SystemRecordCompleteConditionalApplyV1, +): SystemRecordCompleteConditionalApplyV1 { + return Object.freeze({ ...value }); +} + +function markComplete( + value: SystemRecordActiveReplacementCompleteV1, +): SystemRecordActiveReplacementCompleteV1 { + AUTHENTIC_COMPLETE_DERIVATIONS.add(value); + return value; +} + +function canonicalSubjects(values: readonly string[]): readonly string[] { + const unique = [...new Set(values)]; + unique.sort(compareUtf8); + return Object.freeze(unique); +} + +function presentSubjectsFrom( + value: readonly Readonly[], + candidates: ReadonlySet, +): readonly string[] { + if (!Array.isArray(value) || utilTypes.isProxy(value)) return Object.freeze([]); + const present = new Set(); + for (const candidate of value) { + if (candidate === null || typeof candidate !== 'object' || utilTypes.isProxy(candidate)) continue; + const descriptor = Object.getOwnPropertyDescriptor(candidate, 'subject'); + if (descriptor?.enumerable && Object.prototype.hasOwnProperty.call(descriptor, 'value') + && typeof descriptor.value === 'string' && candidates.has(descriptor.value)) { + present.add(descriptor.value); + } + } + return canonicalSubjects([...present]); +} + +function sameTransitions( + left: readonly AgentProfileAppliedTransitionV1[], + right: readonly AgentProfileAppliedTransitionV1[], +): boolean { + return left.length === right.length && left.every((entry, index) => { + const other = right[index]; + return entry.priorAuthoritySequence === other.priorAuthoritySequence + && entry.nextAuthoritySequence === other.nextAuthoritySequence + && entry.transitionDigest === other.transitionDigest; + }); +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} diff --git a/packages/storage/src/system-record-rdf-schema-v1-internal.ts b/packages/storage/src/system-record-rdf-schema-v1-internal.ts new file mode 100644 index 0000000000..c1b4f38dbb --- /dev/null +++ b/packages/storage/src/system-record-rdf-schema-v1-internal.ts @@ -0,0 +1,319 @@ +import { types as utilTypes } from 'node:util'; + +import { + escapeSparqlLiteral, + isSafeIri, + parseCanonicalDecimalU64, +} from '@origintrail-official/dkg-core'; +import { + assertAgentRootV1, + assertNetworkIdV1, + canonicalizeOwnedSubjectTableObjectV1, + canonicalizeSystemRecordAppliedStateV1, + canonicalizeSystemRecordCapacityStateV1, + canonicalizeSystemRecordMaterializationReceiptV1, + canonicalizeSystemRecordRootClaimSetV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordAppliedStateDigestV1, + computeSystemRecordCapacityStateDigestV1, + computeSystemRecordMaterializationReceiptDigestV1, + computeSystemRecordRootClaimSetDigestV1, + parseCanonicalOwnedSubjectTableObjectV1, + parseCanonicalSystemRecordAppliedStateV1, + parseCanonicalSystemRecordCapacityStateV1, + parseCanonicalSystemRecordMaterializationReceiptV1, + parseCanonicalSystemRecordRootClaimSetV1, + type OwnedSubjectTableObjectV1, + type SystemRecordAppliedStatePresentV1, + type SystemRecordCapacityStateV1, + type SystemRecordMaterializationReceiptV1, + type SystemRecordRootClaimSetV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import type { Quad } from './triple-store.js'; +import { + SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH, + SYSTEM_RECORD_V1_STATE_GRAPH, +} from './internal-graph-policy.js'; + +/** + * Fixed RDF vocabulary for the private System Record V1 materializer. + * + * The values are deliberately module-internal. Callers hand the materializer + * verified protocol objects; they never supply a graph, subject or predicate. + * Canonical JSON remains the normative representation, while the surrounding + * RDF gives Oxigraph exact indexed subjects for bounded CAS reads. + */ +const NS = 'urn:dkg:system-record-v1:'; +const UTF8 = new TextEncoder(); +const FATAL_UTF8 = new TextDecoder('utf-8', { fatal: true }); + +export const SYSTEM_RECORD_V1_AUTHORITATIVE_AGENTS_GRAPH = + 'did:dkg:context-graph:agents' as const; + +export const SYSTEM_RECORD_V1_JSON_DATATYPE = `${NS}canonical-json` as const; +export const SYSTEM_RECORD_V1_PREDICATES = Object.freeze({ + appliedState: `${NS}applied-state`, + appliedStateDigest: `${NS}applied-state-digest`, + /** Storage-local frontier; deliberately not part of the frozen B1 applied-state codec. */ + headVersion: `${NS}head-version`, + ownedSubjectTable: `${NS}owned-subject-table`, + ownedSubjectTableDigest: `${NS}owned-subject-table-digest`, + rootClaimSet: `${NS}root-claim-set`, + rootClaimSetDigest: `${NS}root-claim-set-digest`, + capacityState: `${NS}capacity-state`, + capacityStateDigest: `${NS}capacity-state-digest`, + materializationEpoch: `${NS}materialization-epoch`, + receipt: `${NS}materialization-receipt`, + receiptDigest: `${NS}materialization-receipt-digest`, + root: `${NS}root`, + claimedBy: `${NS}claimed-by`, + claimPosition: `${NS}claim-position`, +} as const); + +export type SystemRecordMaterializationModeV1 = 'shadow' | 'authoritative'; + +export interface SystemRecordReservedStateQuadsV1 { + readonly record: readonly Quad[]; + readonly capacity: readonly Quad[]; + readonly epoch: readonly Quad[]; + readonly receipt: readonly Quad[]; + readonly rootClaims: readonly Quad[]; +} + +export function systemRecordProjectionGraphV1( + mode: SystemRecordMaterializationModeV1, +): string { + if (mode === 'shadow') return SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH; + if (mode === 'authoritative') return SYSTEM_RECORD_V1_AUTHORITATIVE_AGENTS_GRAPH; + throw new Error('system-record materialization mode is invalid'); +} + +export function systemRecordRecordSubjectV1(networkId: string, stableKeyHash: string): string { + return fixedSubject(networkId, `record:${digestToken(stableKeyHash)}`); +} + +export function systemRecordCapacitySubjectV1(networkId: string): string { + return fixedSubject(networkId, 'capacity'); +} + +export function systemRecordEpochSubjectV1(networkId: string): string { + return fixedSubject(networkId, 'epoch'); +} + +export function systemRecordReceiptSubjectV1(networkId: string, stableKeyHash: string): string { + return fixedSubject(networkId, `receipt:${digestToken(stableKeyHash)}`); +} + +export function systemRecordRootClaimSubjectV1(networkId: string, root: string): string { + assertAgentRootV1(root); + return fixedSubject(networkId, `root:${base64Url(root)}`); +} + +export function buildSystemRecordReservedStateQuadsV1(input: { + readonly appliedState: SystemRecordAppliedStatePresentV1; + readonly headVersion: string; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly rootClaimSet: SystemRecordRootClaimSetV1; + readonly capacityState: SystemRecordCapacityStateV1; + readonly receipt: SystemRecordMaterializationReceiptV1; +}): SystemRecordReservedStateQuadsV1 { + const outer = snapshotBuildInput(input); + const headVersion = parseCanonicalDecimalU64(outer.headVersion).toString(); + if (headVersion !== outer.headVersion) { + throw new Error('reserved state head version must be canonical'); + } + const stateBytes = canonicalizeSystemRecordAppliedStateV1(outer.appliedState); + const appliedState = parseCanonicalSystemRecordAppliedStateV1(stateBytes); + if (appliedState.state !== 'present') { + throw new Error('reserved state requires one present applied state'); + } + const tableBytes = canonicalizeOwnedSubjectTableObjectV1( + appliedState.currentRoot, + outer.ownedSubjectTable, + ); + const ownedSubjectTable = parseCanonicalOwnedSubjectTableObjectV1( + appliedState.currentRoot, + tableBytes, + ); + const claimsBytes = canonicalizeSystemRecordRootClaimSetV1(outer.rootClaimSet); + const rootClaimSet = parseCanonicalSystemRecordRootClaimSetV1(claimsBytes); + const capacityBytes = canonicalizeSystemRecordCapacityStateV1(outer.capacityState); + const capacityState = parseCanonicalSystemRecordCapacityStateV1(capacityBytes); + const receiptBytes = canonicalizeSystemRecordMaterializationReceiptV1(outer.receipt); + const materializationReceipt = parseCanonicalSystemRecordMaterializationReceiptV1(receiptBytes); + + if (computeSystemRecordAppliedStateDigestV1(appliedState) + !== materializationReceipt.appliedStateDigest) { + throw new Error('materialization receipt does not bind the applied state'); + } + if (computeOwnedSubjectTableDigestV1(appliedState.currentRoot, ownedSubjectTable) + !== appliedState.ownedSubjectTableDigest) { + throw new Error('owned-subject table does not bind the applied state'); + } + if (computeSystemRecordRootClaimSetDigestV1(rootClaimSet) + !== appliedState.rootClaimSetDigest) { + throw new Error('root-claim set does not bind the applied state'); + } + if (rootClaimSet.networkId !== appliedState.networkId + || rootClaimSet.stableKeyHash !== appliedState.stableKeyHash + || capacityState.networkId !== appliedState.networkId + || materializationReceipt.networkId !== appliedState.networkId + || materializationReceipt.stableKeyHash !== appliedState.stableKeyHash + || materializationReceipt.stateRevision !== appliedState.stateRevision + || materializationReceipt.headDigest !== appliedState.headDigest + || materializationReceipt.materializationEpoch !== appliedState.materializationEpoch) { + throw new Error('reserved state objects do not describe one materialization'); + } + + const graph = SYSTEM_RECORD_V1_STATE_GRAPH; + const record = systemRecordRecordSubjectV1( + appliedState.networkId, + appliedState.stableKeyHash, + ); + const capacity = systemRecordCapacitySubjectV1(appliedState.networkId); + const epoch = systemRecordEpochSubjectV1(appliedState.networkId); + const receipt = systemRecordReceiptSubjectV1( + appliedState.networkId, + appliedState.stableKeyHash, + ); + const roots = [rootClaimSet.currentRoot, ...rootClaimSet.historicalRoots]; + + return Object.freeze({ + record: freezeQuads([ + quad(record, SYSTEM_RECORD_V1_PREDICATES.appliedState, jsonLiteral(stateBytes), graph), + quad( + record, + SYSTEM_RECORD_V1_PREDICATES.appliedStateDigest, + stringLiteral(computeSystemRecordAppliedStateDigestV1(appliedState)), + graph, + ), + quad(record, SYSTEM_RECORD_V1_PREDICATES.headVersion, stringLiteral(headVersion), graph), + quad(record, SYSTEM_RECORD_V1_PREDICATES.ownedSubjectTable, jsonLiteral(tableBytes), graph), + quad( + record, + SYSTEM_RECORD_V1_PREDICATES.ownedSubjectTableDigest, + stringLiteral(appliedState.ownedSubjectTableDigest), + graph, + ), + quad(record, SYSTEM_RECORD_V1_PREDICATES.rootClaimSet, jsonLiteral(claimsBytes), graph), + quad( + record, + SYSTEM_RECORD_V1_PREDICATES.rootClaimSetDigest, + stringLiteral(appliedState.rootClaimSetDigest), + graph, + ), + ]), + capacity: freezeQuads([ + quad(capacity, SYSTEM_RECORD_V1_PREDICATES.capacityState, jsonLiteral(capacityBytes), graph), + quad( + capacity, + SYSTEM_RECORD_V1_PREDICATES.capacityStateDigest, + stringLiteral(computeSystemRecordCapacityStateDigestV1(capacityState)), + graph, + ), + ]), + epoch: freezeQuads([ + quad( + epoch, + SYSTEM_RECORD_V1_PREDICATES.materializationEpoch, + stringLiteral(appliedState.materializationEpoch), + graph, + ), + ]), + receipt: freezeQuads([ + quad(receipt, SYSTEM_RECORD_V1_PREDICATES.receipt, jsonLiteral(receiptBytes), graph), + quad( + receipt, + SYSTEM_RECORD_V1_PREDICATES.receiptDigest, + stringLiteral(computeSystemRecordMaterializationReceiptDigestV1(materializationReceipt)), + graph, + ), + ]), + rootClaims: freezeQuads(roots.flatMap((root, index) => { + const claim = systemRecordRootClaimSubjectV1(appliedState.networkId, root); + return [ + quad(claim, SYSTEM_RECORD_V1_PREDICATES.root, root, graph), + quad(claim, SYSTEM_RECORD_V1_PREDICATES.claimedBy, record, graph), + quad( + claim, + SYSTEM_RECORD_V1_PREDICATES.claimPosition, + stringLiteral(index === 0 ? 'current' : `historical:${index - 1}`), + graph, + ), + ]; + })), + }); +} + +function snapshotBuildInput(input: unknown): { + readonly appliedState: SystemRecordAppliedStatePresentV1; + readonly headVersion: string; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly rootClaimSet: SystemRecordRootClaimSetV1; + readonly capacityState: SystemRecordCapacityStateV1; + readonly receipt: SystemRecordMaterializationReceiptV1; +} { + if (input === null || typeof input !== 'object' || Array.isArray(input) + || utilTypes.isProxy(input) + || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) { + throw new Error('reserved-state build input must be a plain data object'); + } + const expected = [ + 'appliedState', 'headVersion', 'ownedSubjectTable', 'rootClaimSet', 'capacityState', 'receipt', + ] as const; + const keys = Reflect.ownKeys(input); + if (keys.length !== expected.length + || keys.some((key) => typeof key !== 'string' || !expected.includes(key as typeof expected[number]))) { + throw new Error('reserved-state build input has unknown or missing fields'); + } + const read = (key: typeof expected[number]): unknown => { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('reserved-state build input fields must be enumerable data properties'); + } + return descriptor.value; + }; + return Object.freeze({ + appliedState: read('appliedState') as SystemRecordAppliedStatePresentV1, + headVersion: read('headVersion') as string, + ownedSubjectTable: read('ownedSubjectTable') as OwnedSubjectTableObjectV1, + rootClaimSet: read('rootClaimSet') as SystemRecordRootClaimSetV1, + capacityState: read('capacityState') as SystemRecordCapacityStateV1, + receipt: read('receipt') as SystemRecordMaterializationReceiptV1, + }); +} + +function fixedSubject(networkId: string, suffix: string): string { + assertNetworkIdV1(networkId); + const iri = `${NS}${base64Url(networkId)}:${suffix}`; + if (!isSafeIri(iri)) throw new Error('derived system-record subject is not a safe IRI'); + return iri; +} + +function digestToken(value: string): string { + if (!/^0x[0-9a-f]{64}$/.test(value)) throw new Error('system-record digest is not canonical'); + return value.slice(2); +} + +function base64Url(value: string): string { + return Buffer.from(UTF8.encode(value)).toString('base64url'); +} + +function jsonLiteral(bytes: Uint8Array): string { + return `"${escapeSparqlLiteral(FATAL_UTF8.decode(bytes))}"^^<${SYSTEM_RECORD_V1_JSON_DATATYPE}>`; +} + +function stringLiteral(value: string): string { + return `"${escapeSparqlLiteral(value)}"`; +} + +function quad(subject: string, predicate: string, object: string, graph: string): Quad { + if (!isSafeIri(subject) || !isSafeIri(predicate) || !isSafeIri(graph)) { + throw new Error('derived system-record RDF IRI is unsafe'); + } + return Object.freeze({ subject, predicate, object, graph }); +} + +function freezeQuads(quads: Quad[]): readonly Quad[] { + return Object.freeze(quads); +} diff --git a/packages/storage/src/system-record-state-snapshot-v1-internal.ts b/packages/storage/src/system-record-state-snapshot-v1-internal.ts new file mode 100644 index 0000000000..c198215f4e --- /dev/null +++ b/packages/storage/src/system-record-state-snapshot-v1-internal.ts @@ -0,0 +1,502 @@ +import { types as utilTypes } from 'node:util'; + +import { parseRdfLiteralTerm } from '@origintrail-official/dkg-rdf-utils'; +import { + assertCanonicalDigest, + isSafeIri, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import { + assertNetworkIdV1, + canonicalizeOwnedSubjectTableObjectV1, + computeSystemRecordAppliedStateDigestV1, + computeSystemRecordCapacityStateDigestV1, + computeSystemRecordMaterializationReceiptDigestV1, + computeSystemRecordRootClaimSetDigestV1, + parseCanonicalOwnedSubjectTableObjectV1, + parseCanonicalSystemRecordAppliedStateV1, + parseCanonicalSystemRecordCapacityStateV1, + parseCanonicalSystemRecordMaterializationReceiptV1, + parseCanonicalSystemRecordRootClaimSetV1, + SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES, + SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES, + SYSTEM_RECORD_MAX_ROOT_CLAIMS, + systemRecordAppliedStateAbsentV1, + type NetworkIdV1, + type OwnedSubjectTableObjectV1, + type SystemRecordAppliedStateAbsentV1, + type SystemRecordAppliedStatePresentV1, + type SystemRecordCapacityStateV1, + type SystemRecordMaterializationReceiptV1, + type SystemRecordRootClaimSetV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import type { Quad } from './triple-store.js'; +import { + buildSystemRecordReservedStateQuadsV1, + systemRecordCapacitySubjectV1, + systemRecordEpochSubjectV1, + systemRecordReceiptSubjectV1, + systemRecordRecordSubjectV1, + SYSTEM_RECORD_V1_JSON_DATATYPE, + SYSTEM_RECORD_V1_PREDICATES, +} from './system-record-rdf-schema-v1-internal.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from './internal-graph-policy.js'; + +export interface SystemRecordAbsentSnapshotV1 { + readonly state: 'absent'; + readonly appliedState: SystemRecordAppliedStateAbsentV1; + readonly capacityState: SystemRecordCapacityStateV1; + readonly materializationEpoch: string; + readonly previousReservedQuads: readonly Readonly[]; + readonly requiredAbsentReservedSubjects: readonly string[]; +} + +export interface SystemRecordPresentSnapshotV1 { + readonly state: 'present'; + readonly appliedState: SystemRecordAppliedStatePresentV1; + /** Storage-local frontier kept outside the frozen B1 applied-state object. */ + readonly headVersion: string; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; + readonly rootClaimSet: SystemRecordRootClaimSetV1; + readonly capacityState: SystemRecordCapacityStateV1; + readonly receipt: SystemRecordMaterializationReceiptV1; + readonly materializationEpoch: string; + readonly previousReservedQuads: readonly Readonly[]; + readonly expectedRootClaimQuads: readonly Readonly[]; + readonly requiredAbsentReservedSubjects: readonly string[]; +} + +export type SystemRecordAppliedSnapshotV1 = + | SystemRecordAbsentSnapshotV1 + | SystemRecordPresentSnapshotV1; + +const AUTHENTIC_APPLIED_SNAPSHOTS = new WeakSet(); + +/** + * Decode the first exact reserved-state read. The result is accepted only when + * it is byte-for-byte the fixed RDF encoding of one canonical object tuple. + */ +export function decodeSystemRecordAppliedSnapshotV1(input: { + readonly networkId: string; + readonly stableKeyHash: string; + readonly materializationEpoch: string; + readonly quads: readonly Readonly[]; +}): SystemRecordAppliedSnapshotV1 { + const owned = snapshotDecoderInput(input); + assertNetworkIdV1(owned.networkId); + assertCanonicalDigest(owned.stableKeyHash); + assertCanonicalU64(owned.materializationEpoch, 'expected materialization epoch'); + const networkId = owned.networkId as NetworkIdV1; + const stableKeyHash = owned.stableKeyHash as Digest32V1; + const recordSubject = systemRecordRecordSubjectV1(networkId, stableKeyHash); + const capacitySubject = systemRecordCapacitySubjectV1(networkId); + const epochSubject = systemRecordEpochSubjectV1(networkId); + const receiptSubject = systemRecordReceiptSubjectV1(networkId, stableKeyHash); + const allowedSubjects = new Set([recordSubject, capacitySubject, epochSubject, receiptSubject]); + const quads = snapshotQuads(owned.quads, allowedSubjects); + const grouped = groupBySubject(quads); + const recordRows = grouped.get(recordSubject) ?? []; + const capacityRows = grouped.get(capacitySubject) ?? []; + const epochRows = grouped.get(epochSubject) ?? []; + const receiptRows = grouped.get(receiptSubject) ?? []; + + const epoch = exactPlainValue( + epochRows, + SYSTEM_RECORD_V1_PREDICATES.materializationEpoch, + 'materialization epoch', + ); + assertCanonicalU64(epoch, 'materialization epoch'); + if (epoch !== owned.materializationEpoch) { + throw new Error('system-record materialization epoch changed during inspection'); + } + + const decodedCapacity = decodeCapacityState(networkId, capacityRows); + + if (recordRows.length === 0) { + if (receiptRows.length !== 0) { + throw new Error('absent system-record state retained receipt rows'); + } + const expectedFirstRead = Object.freeze([ + ...capacityRows, + ...epochRows.filter((quad) => ( + quad.predicate === SYSTEM_RECORD_V1_PREDICATES.materializationEpoch + )), + ]); + assertExactQuadSet(quads, expectedFirstRead, 'absent reserved state'); + return markAuthenticSnapshot(Object.freeze({ + state: 'absent', + appliedState: systemRecordAppliedStateAbsentV1(), + capacityState: decodedCapacity.state, + materializationEpoch: epoch, + previousReservedQuads: expectedFirstRead, + requiredAbsentReservedSubjects: canonicalSortedSubjects([ + recordSubject, + ...(decodedCapacity.persisted ? [] : [capacitySubject]), + receiptSubject, + ]), + })); + } + + const appliedState = parseCanonicalSystemRecordAppliedStateV1(exactJsonValue( + recordRows, + SYSTEM_RECORD_V1_PREDICATES.appliedState, + 'applied state', + )); + if (appliedState.state !== 'present') { + throw new Error('persisted system-record state cannot encode the absent sentinel'); + } + const headVersion = exactPlainValue( + recordRows, + SYSTEM_RECORD_V1_PREDICATES.headVersion, + 'head version', + ); + assertCanonicalU64(headVersion, 'head version'); + const table = parseCanonicalOwnedSubjectTableObjectV1( + appliedState.currentRoot, + exactJsonValue(recordRows, SYSTEM_RECORD_V1_PREDICATES.ownedSubjectTable, 'owned table'), + ); + const claims = parseCanonicalSystemRecordRootClaimSetV1(exactJsonValue( + recordRows, + SYSTEM_RECORD_V1_PREDICATES.rootClaimSet, + 'root claim set', + )); + if (!decodedCapacity.persisted) { + throw new Error('present system-record state requires the global capacity tuple'); + } + const capacity = decodedCapacity.state; + const receipt = parseCanonicalSystemRecordMaterializationReceiptV1(exactJsonValue( + receiptRows, + SYSTEM_RECORD_V1_PREDICATES.receipt, + 'materialization receipt', + )); + + const digests = [ + [recordRows, SYSTEM_RECORD_V1_PREDICATES.appliedStateDigest, + computeSystemRecordAppliedStateDigestV1(appliedState), 'applied-state digest'], + [recordRows, SYSTEM_RECORD_V1_PREDICATES.ownedSubjectTableDigest, + appliedState.ownedSubjectTableDigest, 'owned-table digest'], + [recordRows, SYSTEM_RECORD_V1_PREDICATES.rootClaimSetDigest, + computeSystemRecordRootClaimSetDigestV1(claims), 'root-claim digest'], + [capacityRows, SYSTEM_RECORD_V1_PREDICATES.capacityStateDigest, + computeSystemRecordCapacityStateDigestV1(capacity), 'capacity digest'], + [receiptRows, SYSTEM_RECORD_V1_PREDICATES.receiptDigest, + computeSystemRecordMaterializationReceiptDigestV1(receipt), 'receipt digest'], + ] as const; + for (const [rows, predicate, expected, label] of digests) { + if (exactPlainValue(rows, predicate, label) !== expected) { + throw new Error(`${label} does not match its canonical object`); + } + } + 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) { + throw new Error('persisted system-record tuple crosses its network, key, or epoch binding'); + } + const canonicalTableBytes = canonicalizeOwnedSubjectTableObjectV1( + appliedState.currentRoot, + table, + ).byteLength; + const persistedTableBytes = BigInt(appliedState.ownedSubjectTableBytes); + if (BigInt(table.length) !== BigInt(appliedState.ownedSubjectCount) + || (table.length === 0 + ? persistedTableBytes !== 0n + : BigInt(canonicalTableBytes) !== persistedTableBytes)) { + throw new Error('persisted owned-subject table does not match its count or byte binding'); + } + const pendingTableBytes = 'pendingDeletionTableBytes' in appliedState + ? BigInt(appliedState.pendingDeletionTableBytes as string) + : 0n; + if (BigInt(capacity.liveRecordCount) < 1n + || BigInt(capacity.stateBytes) < BigInt(SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES) + || BigInt(capacity.tableBytes) < persistedTableBytes + pendingTableBytes + || BigInt(capacity.projectionBytes) < BigInt(appliedState.projectionBytes) + || BigInt(capacity.projectionQuads) < BigInt(appliedState.projectionQuads)) { + throw new Error('global capacity tuple does not account for the persisted record'); + } + + const canonical = buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion, + ownedSubjectTable: table, + rootClaimSet: claims, + capacityState: capacity, + receipt, + }); + const expectedFirstRead = Object.freeze([ + ...canonical.record, + ...canonical.capacity, + ...canonical.epoch, + ...canonical.receipt, + ]); + assertExactQuadSet(quads, expectedFirstRead, 'reserved state'); + return markAuthenticSnapshot(Object.freeze({ + state: 'present', + appliedState, + headVersion, + ownedSubjectTable: table, + rootClaimSet: claims, + capacityState: capacity, + receipt, + materializationEpoch: epoch, + previousReservedQuads: expectedFirstRead, + expectedRootClaimQuads: canonical.rootClaims, + requiredAbsentReservedSubjects: Object.freeze([]), + })); +} + +export function assertAuthenticSystemRecordAppliedSnapshotV1( + value: unknown, +): asserts value is SystemRecordAppliedSnapshotV1 { + if (value === null || typeof value !== 'object' + || !AUTHENTIC_APPLIED_SNAPSHOTS.has(value)) { + throw new Error('system-record applied snapshot was not produced by the exact decoder'); + } +} + +export function assertSystemRecordRootClaimSnapshotV1( + actual: readonly Readonly[], + expected: readonly Readonly[], + requiredAbsentSubjects: readonly string[] = [], +): readonly Readonly[] { + if (!Array.isArray(requiredAbsentSubjects) || utilTypes.isProxy(requiredAbsentSubjects) + || requiredAbsentSubjects.length > SYSTEM_RECORD_MAX_ROOT_CLAIMS + || requiredAbsentSubjects.some((subject) => typeof subject !== 'string' || !isSafeIri(subject)) + || new Set(requiredAbsentSubjects).size !== requiredAbsentSubjects.length) { + throw new Error('required-absent root-claim subjects are invalid'); + } + const expectedSubjects = new Set(expected.map((quad) => quad.subject)); + if (requiredAbsentSubjects.some((subject) => expectedSubjects.has(subject))) { + throw new Error('one root claim cannot be expected present and absent'); + } + const subjects = new Set([...expectedSubjects, ...requiredAbsentSubjects]); + const snapshot = snapshotQuads(actual, subjects); + assertExactQuadSet(snapshot, expected, 'root-claim state'); + return snapshot; +} + +function decodeCapacityState( + networkId: NetworkIdV1, + rows: readonly Readonly[], +): Readonly<{ readonly state: SystemRecordCapacityStateV1; readonly persisted: boolean }> { + if (rows.length === 0) { + return Object.freeze({ state: zeroCapacityState(networkId), persisted: false }); + } + const state = parseCanonicalSystemRecordCapacityStateV1(exactJsonValue( + rows, + SYSTEM_RECORD_V1_PREDICATES.capacityState, + 'capacity state', + )); + const liveRecordCount = BigInt(state.liveRecordCount); + if (BigInt(state.stateBytes) + !== liveRecordCount * BigInt(SYSTEM_RECORD_MAX_APPLIED_STATE_BYTES)) { + throw new Error('private capacity state does not use the fixed per-record precharge'); + } + if (liveRecordCount === 0n + && (state.tableBytes !== '0' || state.projectionBytes !== '0' + || state.projectionQuads !== '0')) { + throw new Error('private empty capacity state has nonzero dimensions'); + } + if (exactPlainValue( + rows, + SYSTEM_RECORD_V1_PREDICATES.capacityStateDigest, + 'capacity digest', + ) !== computeSystemRecordCapacityStateDigestV1(state)) { + throw new Error('capacity digest does not match its canonical object'); + } + if (rows.length !== 2 || state.networkId !== networkId) { + throw new Error('capacity tuple does not match the fixed network schema'); + } + return Object.freeze({ state, persisted: true }); +} + +function zeroCapacityState(networkId: NetworkIdV1): SystemRecordCapacityStateV1 { + return Object.freeze({ + objectType: 'system-record-capacity-state', + kind: 'agents', + networkId, + revision: '0', + liveRecordCount: '0', + stateBytes: '0', + tableBytes: '0', + projectionBytes: '0', + projectionQuads: '0', + }) as SystemRecordCapacityStateV1; +} + +function snapshotDecoderInput(value: unknown): Readonly<{ + readonly networkId: string; + readonly stableKeyHash: string; + readonly materializationEpoch: string; + readonly quads: readonly Readonly[]; +}> { + if (value === null || typeof value !== 'object' || Array.isArray(value) || utilTypes.isProxy(value) + || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { + throw new Error('system-record snapshot decoder input must be a plain data object'); + } + const expected = ['networkId', 'stableKeyHash', 'materializationEpoch', 'quads'] as const; + const keys = Reflect.ownKeys(value); + if (keys.length !== expected.length + || keys.some((key) => typeof key !== 'string' || !expected.includes(key as typeof expected[number]))) { + throw new Error('system-record snapshot decoder input has unknown or missing fields'); + } + const fields = Object.create(null) as Record; + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('system-record snapshot decoder fields must be enumerable data properties'); + } + fields[key] = descriptor.value; + } + if (typeof fields.networkId !== 'string' + || typeof fields.stableKeyHash !== 'string' + || typeof fields.materializationEpoch !== 'string' + || !Array.isArray(fields.quads)) { + throw new Error('system-record snapshot decoder fields have invalid scalar types'); + } + return Object.freeze({ + networkId: fields.networkId, + stableKeyHash: fields.stableKeyHash, + materializationEpoch: fields.materializationEpoch, + quads: fields.quads as readonly Readonly[], + }); +} + +function markAuthenticSnapshot(value: T): T { + AUTHENTIC_APPLIED_SNAPSHOTS.add(value); + return value; +} + +function snapshotQuads( + value: readonly Readonly[], + allowedSubjects: ReadonlySet, +): readonly Readonly[] { + if (!Array.isArray(value) || utilTypes.isProxy(value)) { + throw new Error('system-record reserved state exceeds its row bound'); + } + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || lengthDescriptor.enumerable || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 || lengthDescriptor.value > 128) { + throw new Error('system-record reserved state exceeds its row bound'); + } + const length = lengthDescriptor.value as number; + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1) { + throw new Error('system-record reserved state must be a dense data array'); + } + const entries = new Array>(length); + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, index.toString()); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('system-record reserved state must be a dense data array'); + } + entries[index] = descriptor.value as Readonly; + } + let decodedBytes = 0; + const copied = entries.map((quad) => { + if (quad === null || typeof quad !== 'object' || Array.isArray(quad) || utilTypes.isProxy(quad) + || ![Object.prototype, null].includes(Object.getPrototypeOf(quad))) { + throw new Error('system-record reserved state contains a non-data quad'); + } + const keys = Reflect.ownKeys(quad); + if (keys.length !== 4 || !['subject', 'predicate', 'object', 'graph'].every((key) => keys.includes(key))) { + throw new Error('system-record reserved state quad has unknown or missing fields'); + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(quad, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error('system-record reserved state quad fields must be data properties'); + } + } + if (quad.graph !== SYSTEM_RECORD_V1_STATE_GRAPH || !allowedSubjects.has(quad.subject) + || !Object.values(SYSTEM_RECORD_V1_PREDICATES).includes( + quad.predicate as (typeof SYSTEM_RECORD_V1_PREDICATES)[keyof typeof SYSTEM_RECORD_V1_PREDICATES], + ) || typeof quad.object !== 'string') { + throw new Error('system-record reserved state contains an out-of-scope quad'); + } + decodedBytes += Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + Buffer.byteLength(quad.graph, 'utf8'); + if (decodedBytes > SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES) { + throw new Error('system-record reserved state exceeds its decoded byte bound'); + } + return Object.freeze({ ...quad }); + }); + copied.sort(compareQuad); + for (let index = 1; index < copied.length; index += 1) { + if (compareQuad(copied[index - 1], copied[index]) === 0) { + throw new Error('system-record reserved state contains a duplicate quad'); + } + } + return Object.freeze(copied); +} + +function groupBySubject(quads: readonly Readonly[]): ReadonlyMap[]> { + const grouped = new Map[]>(); + for (const quad of quads) { + const rows = grouped.get(quad.subject) ?? []; + rows.push(quad); + grouped.set(quad.subject, rows); + } + return grouped; +} + +function exactJsonValue(rows: readonly Readonly[], predicate: string, label: string): string { + const parsed = exactLiteral(rows, predicate, label); + if (parsed.kind !== 'typed' || parsed.datatype !== SYSTEM_RECORD_V1_JSON_DATATYPE) { + throw new Error(`${label} must use the fixed canonical-JSON datatype`); + } + return parsed.value; +} + +function exactPlainValue(rows: readonly Readonly[], predicate: string, label: string): string { + const parsed = exactLiteral(rows, predicate, label); + if (parsed.kind !== 'plain') throw new Error(`${label} must be a plain RDF literal`); + return parsed.value; +} + +function exactLiteral(rows: readonly Readonly[], predicate: string, label: string) { + const matches = rows.filter((quad) => quad.predicate === predicate); + if (matches.length !== 1) throw new Error(`${label} must have exactly one reserved row`); + const parsed = parseRdfLiteralTerm(matches[0].object); + if (parsed === null) throw new Error(`${label} is not a canonical RDF literal`); + return parsed; +} + +function assertExactQuadSet( + actual: readonly Readonly[], + expected: readonly Readonly[], + label: string, +): void { + const left = [...actual].sort(compareQuad); + const right = [...expected].sort(compareQuad); + if (left.length !== right.length + || left.some((quad, index) => compareQuad(quad, right[index]) !== 0)) { + throw new Error(`${label} does not match the fixed canonical RDF schema`); + } +} + +function assertCanonicalU64(value: string, label: string): void { + if (!/^(0|[1-9][0-9]*)$/.test(value) || value.length > 20 + || BigInt(value) > 0xffff_ffff_ffff_ffffn) { + throw new Error(`${label} must be a canonical decimal u64`); + } +} + +function canonicalSortedSubjects(subjects: readonly string[]): readonly string[] { + return Object.freeze([...subjects].sort((left, right) => Buffer.compare( + Buffer.from(left, 'utf8'), + Buffer.from(right, 'utf8'), + ))); +} + +function compareQuad(left: Readonly, right: Readonly): number { + return left.graph.localeCompare(right.graph) + || left.subject.localeCompare(right.subject) + || left.predicate.localeCompare(right.predicate) + || left.object.localeCompare(right.object); +} diff --git a/packages/storage/src/system-record-utf8-order-v1-internal.ts b/packages/storage/src/system-record-utf8-order-v1-internal.ts new file mode 100644 index 0000000000..f7e17ef374 --- /dev/null +++ b/packages/storage/src/system-record-utf8-order-v1-internal.ts @@ -0,0 +1,21 @@ +/** + * Compare Unicode-scalar strings in canonical UTF-8 byte order without + * allocating encoded buffers inside Array.sort comparators. + * + * UTF-8 preserves scalar-value order. The callers validate scalar Unicode + * before sorting, so a code-point walk is byte-order equivalent while keeping + * maximum-size inspections from allocating on every O(n log n) comparison. + */ +export function compareSystemRecordUtf8V1(left: string, right: string): number { + let leftIndex = 0; + let rightIndex = 0; + while (leftIndex < left.length && rightIndex < right.length) { + const leftPoint = left.codePointAt(leftIndex) as number; + const rightPoint = right.codePointAt(rightIndex) as number; + if (leftPoint !== rightPoint) return leftPoint < rightPoint ? -1 : 1; + leftIndex += leftPoint > 0xffff ? 2 : 1; + rightIndex += rightPoint > 0xffff ? 2 : 1; + } + if (leftIndex === left.length && rightIndex === right.length) return 0; + return leftIndex === left.length ? -1 : 1; +} diff --git a/packages/storage/src/system-record-verified-replacement-v1-internal.ts b/packages/storage/src/system-record-verified-replacement-v1-internal.ts new file mode 100644 index 0000000000..b85c5fae15 --- /dev/null +++ b/packages/storage/src/system-record-verified-replacement-v1-internal.ts @@ -0,0 +1,899 @@ +import { types as utilTypes } from 'node:util'; + +import { + assertSafeIri, + assertSafeRdfTerm, + computeKaBundleProjectionDigestV1, + decodeWorkspaceEncryptionKey, + isSafeIri, + keccak256, + SENTINEL_NO_PRIVATE_V10, + tripleContentV10, + V10MerkleTree, +} from '@origintrail-official/dkg-core'; +import { + assertAgentProfileHeadObjectV1, + assertDerivedAgentEncryptionSubjectV1, + assertAgentProfileVerifiedAuthoritySummaryV1, + assertNetworkIdV1, + canonicalizeAgentProfileHeadObjectV1, + canonicalizeOwnedSubjectTableObjectV1, + AGENT_PROFILE_LINK_PREDICATES_V1, + classifyAgentProfileOwnedSubjectV1, + copyBoundedSystemRecordBytesV1, + computeAgentProfileHeadObjectDigestV1, + computeOwnedSubjectTableDigestV1, + isAllowedAgentProfilePredicateV1, + parseCanonicalAgentProfileHeadObjectV1, + parseCanonicalOwnedSubjectTableObjectV1, + SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES, + SYSTEM_RECORD_MAX_PROJECTION_BYTES, + SYSTEM_RECORD_MAX_RUNTIME_ACCOUNTED_BYTES, + type AgentProfileActiveHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type NetworkIdV1, + type OwnedSubjectTableObjectV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import type { Quad } from './triple-store.js'; + +declare const VERIFIED_REPLACEMENT_HANDLE_BRAND: unique symbol; + +/** + * Process-local, non-serializable authority to one verified active replacement. + * The object is deliberately empty; all facts live in the private table below. + */ +export type SystemRecordVerifiedReplacementHandleV1 = { + readonly [VERIFIED_REPLACEMENT_HANDLE_BRAND]: 'system-record-verified-replacement-v1'; +}; + +export type SystemRecordMaterializationModeV1 = 'shadow' | 'authoritative'; + +/** Every fact that prevents a verified replacement from crossing lifecycle boundaries. */ +export interface SystemRecordVerifiedReplacementLaneBindingV1 { + readonly networkId: NetworkIdV1; + readonly kind: 'agents'; + readonly mode: SystemRecordMaterializationModeV1; + readonly sessionIdentity: object; + readonly activationGeneration: string; + readonly childGeneration: string; + readonly materializationEpoch: string; +} + +export interface SystemRecordVerifiedReplacementBindingsV1 + extends SystemRecordVerifiedReplacementLaneBindingV1 { + readonly admittedDeadlineMs: number; +} + +/** + * Verifier-side input. The issuer must only be captured by the structured verifier; + * handing it to an arbitrary caller would turn that caller into a proof authority. + */ +export interface SystemRecordActiveReplacementIssueV1 + extends SystemRecordVerifiedReplacementBindingsV1 { + readonly head: AgentProfileActiveHeadObjectV1; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; + /** Exact canonical graphless N-Triples bytes verified from the signed profile bundle. */ + readonly canonicalProjectionBytes: Uint8Array; + readonly projectionQuads: readonly Quad[]; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; +} + +/** Deep-owned immutable facts returned once to the storage-side consumer. */ +export interface SystemRecordVerifiedReplacementFactsV1 { + readonly networkId: NetworkIdV1; + readonly kind: 'agents'; + readonly mode: SystemRecordMaterializationModeV1; + readonly activationGeneration: string; + readonly childGeneration: string; + readonly materializationEpoch: string; + readonly admittedDeadlineMs: number; + /** Opaque accountant capability; it carries no mutable or inspectable data. */ + readonly reservationIdentity: object; + readonly head: AgentProfileActiveHeadObjectV1; + readonly verifiedAuthoritySummary: AgentProfileVerifiedAuthoritySummaryV1; + readonly projectionDigest: ReturnType; + /** Graphless verified projection. The materializer derives its reserved graph URI. */ + readonly projectionQuads: readonly Readonly[]; + readonly ownedSubjectTable: OwnedSubjectTableObjectV1; +} + +export interface SystemRecordVerifiedReplacementIssuerV1 { + issueActive(input: SystemRecordActiveReplacementIssueV1): SystemRecordVerifiedReplacementHandleV1; +} + +export interface SystemRecordVerifiedReplacementConsumerV1 { + /** Validate lifecycle binding and reveal only the issuer-bound monotonic deadline. */ + inspectDeadline( + handle: unknown, + expected: SystemRecordVerifiedReplacementLaneBindingV1, + ): number; + /** Release only an authentic proof that has not been consumed into facts. */ + discardProof(handle: unknown): void; + consume( + handle: unknown, + expected: SystemRecordVerifiedReplacementLaneBindingV1, + ): SystemRecordVerifiedReplacementFactsV1; + /** Replace one weighted retained-buffer charge inside the live 12-MiB lease. */ + replaceCharge( + facts: unknown, + category: SystemRecordAtomicChargeCategoryV1, + bytes: number, + ): void; + /** Release an issued handle or consumed facts exactly once. */ + release(value: unknown): void; + /** Atomically retain the charge until the accepted recovery settles. */ + transferToRecovery( + facts: unknown, + ownership: object, + completion: Promise, + ): void; +} + +export type SystemRecordAtomicChargeCategoryV1 = + | 'decoded' + | 'request' + | 'response' + | 'prepared'; + +export interface SystemRecordVerifiedReplacementRegistryV1 { + readonly issuer: SystemRecordVerifiedReplacementIssuerV1; + readonly consumer: SystemRecordVerifiedReplacementConsumerV1; +} + +interface RegisteredReplacementV1 { + readonly registryIdentity: object; + readonly bindings: SystemRecordVerifiedReplacementBindingsV1; + readonly facts: SystemRecordVerifiedReplacementFactsV1; + readonly reservation: RuntimeReservationV1; + used: boolean; +} + +type RuntimeReservationPhaseV1 = 'proof' | 'facts' | 'recovery' | 'released'; + +interface RuntimeReservationV1 { + readonly registryIdentity: object; + readonly identity: object; + readonly bytes: number; + readonly admittedDeadlineMs: number; + readonly charges: Record; + phase: RuntimeReservationPhaseV1; + recoveryOwnership?: object; +} + +/** 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(); +const FACT_RESERVATIONS = new WeakMap(); +const ATOMIC_CHARGE_CATEGORIES = new Set([ + 'decoded', + 'request', + 'response', + 'prepared', +]); + +/** Refuse structural facts even when they embed a separately valid authority capability. */ +export function assertAuthenticSystemRecordVerifiedReplacementFactsV1( + value: unknown, +): asserts value is SystemRecordVerifiedReplacementFactsV1 { + if (value === null || typeof value !== 'object' + || !AUTHENTIC_VERIFIED_REPLACEMENT_FACTS.has(value)) { + throw new Error('verified replacement facts were not produced by this registry'); + } +} + +const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +const DKG = 'https://dkg.network/ontology#'; +const ERC8004 = 'https://eips.ethereum.org/erc-8004#'; +const PROV = 'http://www.w3.org/ns/prov#'; +const SKILL = 'https://dkg.origintrail.io/skill#'; +const IRI_OBJECT_PREDICATES = new Set([ + RDF_TYPE, + ...Object.values(AGENT_PROFILE_LINK_PREDICATES_V1), + `${SKILL}skill`, + `${SKILL}pricing`, + `${DKG}revokedBy`, +]); +const PUBLIC_ENCRYPTION_KEY = `${DKG}publicEncryptionKey`; +const ALLOWED_TYPE_OBJECTS = Object.freeze({ + root: new Set([`${DKG}Agent`, `${DKG}CoreNode`, `${DKG}EdgeNode`]), + capability: new Set([`${ERC8004}Capability`]), + offering: new Set([`${SKILL}SkillOffering`]), + registration: new Set([`${PROV}Activity`]), + hosting: new Set([`${SKILL}HostingProfile`]), + x25519: new Set(), +}); + +const ISSUE_KEYS = [ + 'networkId', + 'kind', + 'mode', + 'sessionIdentity', + 'activationGeneration', + 'childGeneration', + 'materializationEpoch', + 'admittedDeadlineMs', + 'head', + 'verifiedAuthoritySummary', + 'canonicalProjectionBytes', + 'projectionQuads', + 'ownedSubjectTable', +] as const; + +const BINDING_KEYS = [ + 'networkId', + 'kind', + 'mode', + 'sessionIdentity', + 'activationGeneration', + 'childGeneration', + 'materializationEpoch', + 'admittedDeadlineMs', +] as const; + +const LANE_BINDING_KEYS = [ + 'networkId', + 'kind', + 'mode', + 'sessionIdentity', + 'activationGeneration', + 'childGeneration', + 'materializationEpoch', +] as const; + +function exactRecord( + value: unknown, + keys: Keys, + label: string, +): Readonly> { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be a plain data object`); + } + if (utilTypes.isProxy(value)) throw new Error(`${label} must not be a Proxy`); + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} must be a plain data object`); + } + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== keys.length) throw new Error(`${label} has unknown or missing fields`); + const expected = new Set(keys); + const result: Record = Object.create(null); + for (const key of ownKeys) { + if (typeof key !== 'string' || !expected.has(key)) { + throw new Error(`${label} has unknown or missing fields`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} fields must be enumerable data properties`); + } + result[key] = descriptor.value; + } + return Object.freeze(result) as Readonly>; +} + +function denseArray(value: unknown, maxLength: number, label: string): readonly unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array`); + if (utilTypes.isProxy(value)) throw new Error(`${label} must not be a Proxy`); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (!lengthDescriptor || !Object.prototype.hasOwnProperty.call(lengthDescriptor, 'value') + || lengthDescriptor.enumerable === true + || !Number.isSafeInteger(lengthDescriptor.value) + || lengthDescriptor.value < 1 + || lengthDescriptor.value > maxLength) { + throw new Error(`${label} length is outside its bound`); + } + const length = lengthDescriptor.value as number; + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1) throw new Error(`${label} must be a dense closed array`); + const result = new Array(length); + for (const key of keys) { + if (key === 'length') continue; + if (typeof key !== 'string' || !/^(0|[1-9][0-9]*)$/.test(key)) { + throw new Error(`${label} must contain only array indexes`); + } + const index = Number(key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!Number.isSafeInteger(index) || index >= length || !descriptor?.enumerable + || !Object.prototype.hasOwnProperty.call(descriptor, 'value')) { + throw new Error(`${label} must contain only enumerable data elements`); + } + result[index] = descriptor.value; + } + return Object.freeze(result); +} + +function canonicalU64(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length > 20 || !/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error(`${label} must be a canonical decimal u64`); + } + const parsed = BigInt(value); + if (parsed > 0xffff_ffff_ffff_ffffn) throw new Error(`${label} exceeds u64`); + return value; +} + +function identity(value: unknown, label: string): object { + if (value === null || typeof value !== 'object') { + throw new Error(`${label} must be an opaque object identity`); + } + if (utilTypes.isProxy(value)) throw new Error(`${label} must not be a Proxy`); + if (Object.getPrototypeOf(value) !== null || !Object.isFrozen(value) + || Reflect.ownKeys(value).length !== 0) { + throw new Error(`${label} must be a frozen propertyless null-prototype capability`); + } + return value; +} + +function snapshotLaneBinding(value: unknown): SystemRecordVerifiedReplacementLaneBindingV1 { + const input = exactRecord(value, LANE_BINDING_KEYS, 'verified replacement lane binding'); + assertNetworkIdV1(input.networkId); + if (input.kind !== 'agents') throw new Error('verified replacement kind must be agents'); + if (input.mode !== 'shadow' && input.mode !== 'authoritative') { + throw new Error('verified replacement mode is invalid'); + } + return Object.freeze({ + networkId: input.networkId, + kind: 'agents', + mode: input.mode, + sessionIdentity: identity(input.sessionIdentity, 'sessionIdentity'), + activationGeneration: canonicalU64(input.activationGeneration, 'activationGeneration'), + childGeneration: canonicalU64(input.childGeneration, 'childGeneration'), + materializationEpoch: canonicalU64(input.materializationEpoch, 'materializationEpoch'), + }); +} + +function snapshotBindings(value: unknown): SystemRecordVerifiedReplacementBindingsV1 { + const input = exactRecord(value, BINDING_KEYS, 'verified replacement bindings'); + assertNetworkIdV1(input.networkId); + if (input.kind !== 'agents') throw new Error('verified replacement kind must be agents'); + if (input.mode !== 'shadow' && input.mode !== 'authoritative') { + throw new Error('verified replacement mode is invalid'); + } + if (!Number.isSafeInteger(input.admittedDeadlineMs) || (input.admittedDeadlineMs as number) < 0) { + throw new Error('admittedDeadlineMs must be a non-negative safe integer'); + } + return Object.freeze({ + networkId: input.networkId as NetworkIdV1, + kind: 'agents', + mode: input.mode, + sessionIdentity: identity(input.sessionIdentity, 'sessionIdentity'), + activationGeneration: canonicalU64(input.activationGeneration, 'activationGeneration'), + childGeneration: canonicalU64(input.childGeneration, 'childGeneration'), + materializationEpoch: canonicalU64(input.materializationEpoch, 'materializationEpoch'), + admittedDeadlineMs: input.admittedDeadlineMs as number, + }); +} + +function assertCanonicalProjectionBytesForQuads( + quads: readonly Readonly[], + bytes: Uint8Array, + expectedContentDigest: string, +): void { + let cursor = 0; + let previousStart = -1; + let previousEnd = -1; + const leaves: Uint8Array[] = []; + for (const quad of quads) { + const line = tripleContentV10(quad.subject, quad.predicate, quad.object); + if (cursor + line.byteLength + 1 > bytes.byteLength) { + throw new Error('verified projection quads exceed their canonical bytes'); + } + for (let index = 0; index < line.byteLength; index += 1) { + if (bytes[cursor + index] !== line[index]) { + throw new Error('verified projection quads do not exactly match their canonical bytes'); + } + } + if (bytes[cursor + line.byteLength] !== 0x0a) { + throw new Error('canonical verified projection lines must end with one LF'); + } + if (previousStart >= 0) { + const previousLength = previousEnd - previousStart; + const sharedLength = Math.min(previousLength, line.byteLength); + let order = 0; + for (let index = 0; index < sharedLength; index += 1) { + if (bytes[previousStart + index] !== line[index]) { + order = bytes[previousStart + index] < line[index] ? -1 : 1; + break; + } + } + if (order === 0) order = previousLength < line.byteLength ? -1 : previousLength === line.byteLength ? 0 : 1; + if (order >= 0) throw new Error('verified projection must be UTF-8 sorted and duplicate-free'); + } + leaves.push(keccak256(line)); + previousStart = cursor; + previousEnd = cursor + line.byteLength; + cursor = previousEnd + 1; + } + if (cursor !== bytes.byteLength) { + throw new Error('canonical verified projection contains bytes not represented by its quads'); + } + const publicRoot = new V10MerkleTree(leaves).root; + const contentRoot = V10MerkleTree.computeKARoot(publicRoot, SENTINEL_NO_PRIVATE_V10); + if (`0x${Buffer.from(contentRoot).toString('hex')}` !== expectedContentDigest) { + throw new Error('verified projection does not reproduce the active head content digest'); + } +} + +function snapshotProjection( + value: unknown, + rootSubject: string, + ownedSubjects: ReadonlySet, + expectedCount: bigint, + expectedBytes: bigint, +): readonly Readonly[] { + if (expectedCount > BigInt(Number.MAX_SAFE_INTEGER) + || expectedBytes > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('projection dimensions are outside local bounds'); + } + const input = denseArray(value, Number(expectedCount), 'verified projection'); + if (input.length !== Number(expectedCount)) { + throw new Error('verified projection does not match the active head quad count'); + } + let observedUtf8Bytes = 0; + const maxUtf8Bytes = Number(expectedBytes); + const copied = input.map((candidate, index) => { + const quad = exactRecord( + candidate, + ['subject', 'predicate', 'object', 'graph'] as const, + `verified projection quad ${index}`, + ); + if (typeof quad.subject !== 'string' || quad.subject.length > maxUtf8Bytes + || !isSafeIri(quad.subject)) { + throw new Error(`verified projection quad ${index} has an invalid subject IRI`); + } + if (typeof quad.predicate !== 'string' || quad.predicate.length > maxUtf8Bytes + || !isSafeIri(quad.predicate)) { + throw new Error(`verified projection quad ${index} has an invalid predicate IRI`); + } + assertSafeIri(quad.subject); + assertSafeIri(quad.predicate); + if (!ownedSubjects.has(quad.subject)) { + throw new Error(`verified projection quad ${index} has an unowned subject`); + } + const subjectKind = classifyAgentProfileOwnedSubjectV1(rootSubject, quad.subject); + if (subjectKind === null || !isAllowedAgentProfilePredicateV1(subjectKind, quad.predicate)) { + throw new Error(`verified projection quad ${index} uses a disallowed profile predicate`); + } + if (typeof quad.object !== 'string' || quad.object.length > maxUtf8Bytes) { + throw new Error(`verified projection quad ${index} has an invalid object`); + } + observedUtf8Bytes += Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + (quad.object.startsWith('"') ? 9 : 11); + if (observedUtf8Bytes > maxUtf8Bytes) { + throw new Error('verified projection terms exceed the active head byte count'); + } + if (quad.object.startsWith('"')) assertSafeRdfTerm(quad.object); + else if (!isSafeIri(quad.object)) { + throw new Error(`verified projection quad ${index} has a noncanonical object IRI`); + } else assertSafeIri(quad.object); + if (quad.graph !== '') { + throw new Error('verified projections must be graphless; the materializer derives graph scope'); + } + return Object.freeze({ + subject: quad.subject, + predicate: quad.predicate, + object: quad.object, + graph: '', + }); + }); + return Object.freeze(copied); +} + +function validateProjectionSchema( + rootSubject: string, + ownedSubjectTable: OwnedSubjectTableObjectV1, + quads: readonly Readonly[], +): void { + const linked = new Set(); + const seenSubjects = new Set(); + const ownedSubjects = new Set(ownedSubjectTable); + const publicKeys: Uint8Array[] = []; + for (const quad of quads) { + seenSubjects.add(quad.subject); + const subjectKind = classifyAgentProfileOwnedSubjectV1(rootSubject, quad.subject); + if (subjectKind === null) throw new Error('verified projection contains an unknown subject kind'); + const objectIsLiteral = quad.object.startsWith('"'); + if (IRI_OBJECT_PREDICATES.has(quad.predicate) === objectIsLiteral) { + throw new Error('verified projection predicate has an invalid object term kind'); + } + if (quad.predicate === RDF_TYPE && !ALLOWED_TYPE_OBJECTS[subjectKind].has(quad.object)) { + throw new Error('verified projection rdf:type object is outside the frozen profile schema'); + } + if (quad.subject === rootSubject) { + const linkKind = Object.entries(AGENT_PROFILE_LINK_PREDICATES_V1) + .find(([, predicate]) => predicate === quad.predicate)?.[0]; + if (linkKind !== undefined) { + if (objectIsLiteral || !ownedSubjects.has(quad.object) + || classifyAgentProfileOwnedSubjectV1(rootSubject, quad.object) !== linkKind) { + throw new Error('verified profile link does not target its exact derived-subject kind'); + } + linked.add(quad.object); + } + if (quad.predicate === PUBLIC_ENCRYPTION_KEY) { + const match = /^"([A-Za-z0-9_-]{43})"$/.exec(quad.object); + if (match === null) throw new Error('verified profile public encryption key is not canonical'); + try { + publicKeys.push(decodeWorkspaceEncryptionKey(match[1])); + } catch (cause) { + throw new Error('verified profile public encryption key is invalid', { cause }); + } + } + } + if (subjectKind === 'x25519' && quad.predicate === `${DKG}revokedBy` + && quad.object !== rootSubject) { + throw new Error('verified x25519 revocation does not bind the profile root'); + } + } + for (const subject of ownedSubjectTable) { + if (!seenSubjects.has(subject)) { + throw new Error('owned-subject table contains a subject absent from the projection'); + } + const kind = classifyAgentProfileOwnedSubjectV1(rootSubject, subject); + if (kind === 'capability' || kind === 'offering' || kind === 'registration' || kind === 'hosting') { + if (!linked.has(subject)) throw new Error('verified derived profile subject is not linked from the root'); + } else if (kind === 'x25519') { + const derived = publicKeys.some((key) => { + try { + assertDerivedAgentEncryptionSubjectV1(rootSubject, subject, key); + return true; + } catch { + return false; + } + }); + if (!derived) throw new Error('verified x25519 subject is not derived from a profile public key'); + } + } +} + +function bindingsEqual( + actual: SystemRecordVerifiedReplacementBindingsV1, + expected: SystemRecordVerifiedReplacementLaneBindingV1, +): boolean { + return actual.networkId === expected.networkId + && actual.kind === expected.kind + && actual.mode === expected.mode + && actual.sessionIdentity === expected.sessionIdentity + && actual.activationGeneration === expected.activationGeneration + && actual.childGeneration === expected.childGeneration + && actual.materializationEpoch === expected.materializationEpoch; +} + +/** + * 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 { + const registryIdentity = Object.freeze(Object.create(null) as object); + let accountedBytes = 0; + let liveAtomicReservation: RuntimeReservationV1 | null = null; + + 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'); + } + const reservation: RuntimeReservationV1 = { + registryIdentity, + identity: Object.freeze(Object.create(null) as object), + bytes: SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES, + admittedDeadlineMs, + charges: { + decoded: decodedBytes, + request: 0, + response: 0, + prepared: 0, + }, + phase: 'proof', + }; + accountedBytes += reservation.bytes; + liveAtomicReservation = reservation; + return reservation; + }; + + const releaseReservation = (reservation: RuntimeReservationV1): void => { + 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'); + } + 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 => { + if (handle === null || typeof handle !== 'object') { + throw new Error('verified replacement handle is invalid'); + } + const registered = REGISTERED_REPLACEMENTS.get(handle); + if (registered === undefined || registered.registryIdentity !== registryIdentity) { + throw new Error('verified replacement handle is invalid or belongs to another registry'); + } + return registered; + }; + + const reservationForFacts = (facts: unknown): RuntimeReservationV1 => { + if (facts === null || typeof facts !== 'object') { + throw new Error('verified replacement facts are invalid'); + } + const reservation = FACT_RESERVATIONS.get(facts); + if (reservation === undefined || reservation.registryIdentity !== registryIdentity) { + throw new Error('verified replacement facts belong to another registry'); + } + return reservation; + }; + + const issuer: SystemRecordVerifiedReplacementIssuerV1 = Object.freeze({ + issueActive(value: SystemRecordActiveReplacementIssueV1): SystemRecordVerifiedReplacementHandleV1 { + const input = exactRecord(value, ISSUE_KEYS, 'active verified replacement'); + const bindings = snapshotBindings(Object.fromEntries( + BINDING_KEYS.map((key) => [key, input[key]]), + )); + // Reserve before any head/projection decode or snapshot allocation. A + // concurrent verifier cannot build a second maximum object graph and only + // then discover that the single nonqueued lease was unavailable. + const reservation = reserveAtomic(bindings.admittedDeadlineMs, 0); + + try { + if (input.head !== null && typeof input.head === 'object' && utilTypes.isProxy(input.head)) { + throw new Error('verified replacement head must not be a Proxy'); + } + if (input.head !== null && typeof input.head === 'object') { + const sealDescriptor = Object.getOwnPropertyDescriptor(input.head, 'graphScopedAuthorSeal'); + if (sealDescriptor && Object.prototype.hasOwnProperty.call(sealDescriptor, 'value') + && sealDescriptor.value !== null && typeof sealDescriptor.value === 'object' + && utilTypes.isProxy(sealDescriptor.value)) { + throw new Error('verified replacement graph-scoped author seal must not be a Proxy'); + } + } + assertAgentProfileHeadObjectV1(input.head); + const head = parseCanonicalAgentProfileHeadObjectV1( + canonicalizeAgentProfileHeadObjectV1(input.head), + ); + if (head.state !== 'active') throw new Error('verified replacement head must be active'); + if (head.networkId !== bindings.networkId) { + throw new Error('verified replacement head does not bind networkId'); + } + + assertAgentProfileVerifiedAuthoritySummaryV1(input.verifiedAuthoritySummary); + const authority = input.verifiedAuthoritySummary; + if (authority.candidateHeadDigest !== computeAgentProfileHeadObjectDigestV1(head)) { + throw new Error('verified authority summary does not bind the active head'); + } + + if (utilTypes.isProxy(input.ownedSubjectTable)) { + throw new Error('owned-subject table must not be a Proxy'); + } + const subjectTable = parseCanonicalOwnedSubjectTableObjectV1( + head.rootSubject, + canonicalizeOwnedSubjectTableObjectV1( + head.rootSubject, + input.ownedSubjectTable as OwnedSubjectTableObjectV1, + ), + ); + if (BigInt(subjectTable.length) !== BigInt(head.ownedSubjectCount) + || computeOwnedSubjectTableDigestV1(head.rootSubject, subjectTable) + !== head.ownedSubjectTableDigest) { + throw new Error('owned-subject table does not match the active head'); + } + if (input.canonicalProjectionBytes !== null + && typeof input.canonicalProjectionBytes === 'object' + && utilTypes.isProxy(input.canonicalProjectionBytes)) { + throw new Error('canonical verified projection must not be a Proxy'); + } + const canonicalProjectionBytes = copyBoundedSystemRecordBytesV1( + input.canonicalProjectionBytes, + SYSTEM_RECORD_MAX_PROJECTION_BYTES, + 'canonical verified projection', + ); + if (BigInt(canonicalProjectionBytes.byteLength) !== BigInt(head.projectionBytes)) { + throw new Error('canonical verified projection does not match the active head byte count'); + } + const projection = snapshotProjection( + input.projectionQuads, + head.rootSubject, + new Set(subjectTable), + BigInt(head.projectionQuads), + BigInt(canonicalProjectionBytes.byteLength), + ); + validateProjectionSchema(head.rootSubject, subjectTable, projection); + assertCanonicalProjectionBytesForQuads( + projection, + canonicalProjectionBytes, + head.contentDigest, + ); + const projectionDigest = computeKaBundleProjectionDigestV1(canonicalProjectionBytes); + + const decodedBytes = retainedVerifiedFactsBytes( + head, + authority, + subjectTable, + projection, + ); + if (decodedBytes > reservation.bytes) { + throw new Error('verified replacement decoded state exceeds its transient lease'); + } + reservation.charges.decoded = decodedBytes; + + const facts: SystemRecordVerifiedReplacementFactsV1 = Object.freeze({ + networkId: bindings.networkId, + kind: bindings.kind, + mode: bindings.mode, + activationGeneration: bindings.activationGeneration, + childGeneration: bindings.childGeneration, + materializationEpoch: bindings.materializationEpoch, + admittedDeadlineMs: bindings.admittedDeadlineMs, + reservationIdentity: reservation.identity, + head, + verifiedAuthoritySummary: authority, + projectionDigest, + projectionQuads: projection, + ownedSubjectTable: subjectTable, + }); + AUTHENTIC_VERIFIED_REPLACEMENT_FACTS.add(facts); + FACT_RESERVATIONS.set(facts, reservation); + const handle = Object.freeze( + Object.create(null) as object, + ) as SystemRecordVerifiedReplacementHandleV1; + REGISTERED_REPLACEMENTS.set(handle, { + registryIdentity, + bindings, + facts, + reservation, + used: false, + }); + return handle; + } catch (error) { + if (reservation.phase !== 'released') releaseReservation(reservation); + throw error; + } + }, + }); + + const consumer: SystemRecordVerifiedReplacementConsumerV1 = Object.freeze({ + inspectDeadline( + handle: unknown, + expectedValue: SystemRecordVerifiedReplacementLaneBindingV1, + ): number { + const registered = registeredHandle(handle); + if (registered.used) throw new Error('verified replacement handle was already consumed'); + if (registered.reservation.phase !== 'proof') { + throw new Error('verified replacement reservation is no longer live'); + } + const expected = snapshotLaneBinding(expectedValue); + if (!bindingsEqual(registered.bindings, expected)) { + throw new Error('verified replacement handle does not match the active lifecycle binding'); + } + return registered.reservation.admittedDeadlineMs; + }, + discardProof(handle: unknown): void { + const registered = registeredHandle(handle); + if (registered.used || registered.reservation.phase !== 'proof') { + throw new Error('verified replacement proof is no longer live and unconsumed'); + } + releaseReservation(registered.reservation); + }, + consume( + handle: unknown, + expectedValue: SystemRecordVerifiedReplacementLaneBindingV1, + ): SystemRecordVerifiedReplacementFactsV1 { + const registered = registeredHandle(handle); + if (registered.used) throw new Error('verified replacement handle was already consumed'); + if (registered.reservation.phase !== 'proof') { + throw new Error('verified replacement reservation is no longer live'); + } + const expected = snapshotLaneBinding(expectedValue); + if (!bindingsEqual(registered.bindings, expected)) { + throw new Error('verified replacement handle does not match the active lifecycle binding'); + } + + // Consume before exposing facts. No callback or await can interleave this transition. + registered.used = true; + registered.reservation.phase = 'facts'; + return registered.facts; + }, + replaceCharge( + facts: unknown, + category: SystemRecordAtomicChargeCategoryV1, + bytes: number, + ): void { + const reservation = reservationForFacts(facts); + if (reservation.phase !== 'facts' && reservation.phase !== 'recovery') { + throw new Error('system-record atomic transient reservation is not live'); + } + if (!ATOMIC_CHARGE_CATEGORIES.has(category)) { + throw new Error('system-record atomic transient charge category is invalid'); + } + if (!Number.isSafeInteger(bytes) || bytes < 0 + || bytes > SYSTEM_RECORD_MAX_ATOMIC_TRANSIENT_BYTES) { + throw new Error('system-record atomic transient charge is outside its bound'); + } + const previous = reservation.charges[category]; + const nextTotal = Object.values(reservation.charges) + .reduce((total, charge) => total + charge, 0) - previous + bytes; + if (nextTotal > reservation.bytes) { + throw new Error('system-record atomic transient lease capacity exceeded'); + } + reservation.charges[category] = bytes; + }, + release(value: unknown): void { + let reservation: RuntimeReservationV1; + if (value !== null && typeof value === 'object' + && REGISTERED_REPLACEMENTS.has(value)) { + reservation = registeredHandle(value).reservation; + } else { + reservation = reservationForFacts(value); + } + if (reservation.phase === 'recovery') { + throw new Error('system-record atomic transient reservation belongs to recovery'); + } + releaseReservation(reservation); + }, + transferToRecovery( + facts: unknown, + ownership: object, + completion: Promise, + ): void { + const reservation = reservationForFacts(facts); + if (reservation.phase !== 'facts') { + throw new Error('system-record atomic transient reservation is not consumer-owned'); + } + const recoveryOwnership = identity(ownership, 'recovery ownership'); + if (!(completion instanceof Promise)) { + throw new Error('system-record recovery completion must be a Promise'); + } + reservation.phase = 'recovery'; + reservation.recoveryOwnership = recoveryOwnership; + void completion.then( + () => { + if (reservation.phase === 'recovery' + && reservation.recoveryOwnership === recoveryOwnership) { + releaseReservation(reservation); + } + }, + () => { + if (reservation.phase === 'recovery' + && reservation.recoveryOwnership === recoveryOwnership) { + releaseReservation(reservation); + } + }, + ); + }, + }); + + return Object.freeze({ issuer, consumer }); +} + +function retainedVerifiedFactsBytes( + head: AgentProfileActiveHeadObjectV1, + authority: AgentProfileVerifiedAuthoritySummaryV1, + subjectTable: OwnedSubjectTableObjectV1, + projection: readonly Readonly[], +): number { + // The ADR weights retained JS strings at two bytes/code unit and Quad/ + // container entries at 128 bytes. JSON here is already verifier-produced, + // deeply frozen plain data, so serialization invokes no caller accessors. + let bytes = 2 * Buffer.byteLength(JSON.stringify(head), 'utf8') + + 2 * Buffer.byteLength(JSON.stringify(authority), 'utf8') + + 2 * Buffer.byteLength(JSON.stringify(subjectTable), 'utf8'); + for (const quad of projection) { + bytes += 2 * ( + Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + ) + 128; + if (!Number.isSafeInteger(bytes)) return Number.MAX_SAFE_INTEGER; + } + return bytes; +} diff --git a/packages/storage/test/helpers/system-record-active-replacement-fixture.ts b/packages/storage/test/helpers/system-record-active-replacement-fixture.ts new file mode 100644 index 0000000000..d68157d3a9 --- /dev/null +++ b/packages/storage/test/helpers/system-record-active-replacement-fixture.ts @@ -0,0 +1,203 @@ +import { readFileSync } from 'node:fs'; + +import { + keccak256, + SENTINEL_NO_PRIVATE_V10, + tripleContentV10, + V10MerkleTree, +} from '@origintrail-official/dkg-core'; +import { + buildAgentProfileVerificationClosureV1, + canonicalizeSignedSystemRecordEnvelopeV1, + computeAgentProfileHeadObjectDigestV1, + computeSystemRecordStableKeyHashV1, + digestSystemRecordBytesV1, + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + type AgentProfileActiveHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type NetworkIdV1, + type SignedAgentProfileHeadEnvelopeV1, +} from '@origintrail-official/dkg-core/system-record-v1'; + +import { SYSTEM_RECORD_V1_STATE_GRAPH } from '../../src/internal-graph-policy.js'; +import { + deriveSystemRecordActiveReplacementV1, + type SystemRecordActiveReplacementReadyV1, +} from '../../src/system-record-next-state-v1-internal.js'; +import { + SYSTEM_RECORD_V1_PREDICATES, + systemRecordEpochSubjectV1, +} from '../../src/system-record-rdf-schema-v1-internal.js'; +import { decodeSystemRecordAppliedSnapshotV1 } from '../../src/system-record-state-snapshot-v1-internal.js'; +import { + createSystemRecordVerifiedReplacementRegistryV1, + type SystemRecordActiveReplacementIssueV1, +} from '../../src/system-record-verified-replacement-v1-internal.js'; +import type { SystemRecordLaneExecutionBindingV1 } from '../../src/system-record-materializer-v1.js'; +import type { Quad } from '../../src/triple-store.js'; + +interface Vectors { + readonly variants: { readonly active: { readonly object: AgentProfileActiveHeadObjectV1 } }; + readonly signed: { readonly activeEip191: { readonly envelope: SignedAgentProfileHeadEnvelopeV1 } }; +} + +const vectors = JSON.parse(readFileSync(new URL( + '../../../core/test/fixtures/system-record-v1/vectors.json', + import.meta.url, +), 'utf8')) as Vectors; + +const verified = await (async () => { + const source = structuredClone(vectors.variants.active.object); + const projectionQuads = projectionFor(source.rootSubject); + const canonicalProjectionBytes = canonicalBytesFor(projectionQuads); + const contentDigest = contentDigestFor(projectionQuads); + const bundle = new TextEncoder().encode('verified-profile-bundle'); + const head = { + ...source, + projectionBytes: String(canonicalProjectionBytes.byteLength), + projectionQuads: String(projectionQuads.length), + contentDigest, + graphScopedAuthorSeal: { + ...source.graphScopedAuthorSeal, + assertionMerkleRoot: contentDigest, + publicTripleCount: String(projectionQuads.length), + }, + bundleDigest: digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + bundle, + ), + } as AgentProfileActiveHeadObjectV1; + return Object.freeze({ + head, + authority: await mintAuthority(head, bundle), + projectionQuads, + canonicalProjectionBytes, + }); +})(); + +export const SYSTEM_RECORD_FIXTURE_NETWORK = verified.head.networkId as NetworkIdV1; + +export interface AuthenticActiveReplacementFixtureV1 { + readonly binding: SystemRecordLaneExecutionBindingV1; + readonly epochQuad: Readonly; + readonly ready: SystemRecordActiveReplacementReadyV1; +} + +/** Exercise the real verifier registry, snapshot decoder and transition factory. */ +export function makeAuthenticActiveReplacementFixtureV1( + mode: 'shadow' | 'authoritative' = 'shadow', +): AuthenticActiveReplacementFixtureV1 { + const binding = Object.freeze({ + activationGeneration: '1', + networkId: SYSTEM_RECORD_FIXTURE_NETWORK, + kind: 'agents', + mode, + sessionIdentity: Object.freeze(Object.create(null) as object), + childGeneration: '2', + materializationEpoch: '2', + }) satisfies SystemRecordLaneExecutionBindingV1; + const epochQuad = Object.freeze({ + subject: systemRecordEpochSubjectV1(SYSTEM_RECORD_FIXTURE_NETWORK), + predicate: SYSTEM_RECORD_V1_PREDICATES.materializationEpoch, + object: '"2"', + graph: SYSTEM_RECORD_V1_STATE_GRAPH, + }); + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const facts = registry.consumer.consume( + registry.issuer.issueActive(issue(binding)), + binding, + ); + const snapshot = decodeSystemRecordAppliedSnapshotV1({ + networkId: SYSTEM_RECORD_FIXTURE_NETWORK, + stableKeyHash: computeSystemRecordStableKeyHashV1( + SYSTEM_RECORD_FIXTURE_NETWORK, + verified.head.peerId, + ), + materializationEpoch: binding.materializationEpoch, + quads: [epochQuad], + }); + const derivation = deriveSystemRecordActiveReplacementV1({ + facts, + snapshot, + observedRootClaimQuads: [], + }); + if (derivation.outcome !== 'ready') { + throw new Error(`authentic active fixture derivation was ${derivation.outcome}`); + } + return Object.freeze({ binding, epochQuad, ready: derivation }); +} + +function issue(binding: SystemRecordLaneExecutionBindingV1): SystemRecordActiveReplacementIssueV1 { + return { + ...binding, + networkId: SYSTEM_RECORD_FIXTURE_NETWORK, + admittedDeadlineMs: 10_000, + head: structuredClone(verified.head), + verifiedAuthoritySummary: verified.authority, + canonicalProjectionBytes: new Uint8Array(verified.canonicalProjectionBytes), + projectionQuads: structuredClone(verified.projectionQuads), + ownedSubjectTable: [verified.head.rootSubject], + }; +} + +function projectionFor(root: string) { + return [ + { + subject: root, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'https://dkg.network/ontology#Agent', + graph: '', + }, + { subject: root, predicate: 'https://schema.org/description', object: '"b"', graph: '' }, + { subject: root, predicate: 'https://schema.org/name', object: '"a"', graph: '' }, + ] as const; +} + +function canonicalBytesFor( + quads: readonly Readonly<{ subject: string; predicate: string; object: string }>[], +) { + return new TextEncoder().encode(quads.map((quad) => + `${new TextDecoder().decode(tripleContentV10(quad.subject, quad.predicate, quad.object))}\n`) + .join('')); +} + +function contentDigestFor( + quads: readonly Readonly<{ subject: string; predicate: string; object: string }>[], +) { + const leaves = quads.map((quad) => + keccak256(tripleContentV10(quad.subject, quad.predicate, quad.object))); + return `0x${Buffer.from(V10MerkleTree.computeKARoot( + new V10MerkleTree(leaves).root, + SENTINEL_NO_PRIVATE_V10, + )).toString('hex')}` as const; +} + +async function mintAuthority( + head: AgentProfileActiveHeadObjectV1, + bundle: Uint8Array, +): Promise { + const envelope = { + ...structuredClone(vectors.signed.activeEip191.envelope), + object: head, + objectDigest: computeAgentProfileHeadObjectDigestV1(head), + } as SignedAgentProfileHeadEnvelopeV1; + const artifacts = new Map([ + [`agent-profile-head:${envelope.objectDigest}`, { + objectKind: 'agent-profile-head' as const, + digest: envelope.objectDigest, + canonicalBytes: canonicalizeSignedSystemRecordEnvelopeV1(envelope), + }], + [`profile-bundle:${head.bundleDigest}`, { + objectKind: 'profile-bundle' as const, + digest: head.bundleDigest, + canonicalBytes: bundle, + }], + ]); + const closure = await buildAgentProfileVerificationClosureV1(envelope.objectDigest, { + nowMs: Date.parse('2026-08-05T12:10:00Z'), + resolve: async (reference) => artifacts.get(`${reference.objectKind}:${reference.digest}`), + verifyAuthorityEnvelope: () => true, + verifyCurrentBundle: (_head, bytes) => Buffer.from(bytes).equals(Buffer.from(bundle)), + }); + return closure.authoritySummary; +} diff --git a/packages/storage/test/managed-http-client-v1.test.ts b/packages/storage/test/managed-http-client-v1.test.ts index 4132521eb6..cce1ed0fd7 100644 --- a/packages/storage/test/managed-http-client-v1.test.ts +++ b/packages/storage/test/managed-http-client-v1.test.ts @@ -1,5 +1,6 @@ +import { getEventListeners } from 'node:events'; import { createServer, type Server } from 'node:http'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { OwnedManagedHttpClient } from '../src/adapters/managed-http-client.js'; @@ -9,9 +10,13 @@ import { OwnedManagedHttpClient } from '../src/adapters/managed-http-client.js'; */ let server: Server; let base: string; +let requestCount = 0; +let overflowTailStarted = false; +let overflowClosedBeforeTail = false; beforeAll(async () => { server = createServer((req, res) => { + requestCount += 1; if (req.url === '/slow') { setTimeout(() => { res.writeHead(200); @@ -19,6 +24,50 @@ beforeAll(async () => { }, 1_500); return; } + if (req.url === '/exact-boundary') { + res.writeHead(200); + res.end('€x'); // Four UTF-8 bytes, two JavaScript code units. + return; + } + if (req.url === '/chunked-overflow') { + overflowTailStarted = false; + overflowClosedBeforeTail = false; + res.on('error', () => undefined); + res.on('close', () => { + if (!overflowTailStarted) overflowClosedBeforeTail = true; + }); + res.writeHead(200, { 'Transfer-Encoding': 'chunked' }); + res.write('1234'); + setTimeout(() => res.write('56'), 10); + setTimeout(() => { + overflowTailStarted = true; + if (!res.destroyed) res.end('tail'); + }, 150); + return; + } + if (req.url === '/chunked-exact') { + res.writeHead(200, { 'Transfer-Encoding': 'chunked' }); + res.write('12'); + res.write('34'); + res.end('5'); + return; + } + if (req.url === '/hang') { + res.on('error', () => undefined); + res.writeHead(200); + res.write('x'); + return; + } + if (req.url === '/truncated') { + res.writeHead(200, { 'Content-Length': '5', Connection: 'close' }); + res.end('abc'); + return; + } + if (req.url === '/legacy-large') { + res.writeHead(200); + res.end('x'.repeat(4 * 1024 * 1024 + 1)); + return; + } res.writeHead(200); res.end('ok'); }); @@ -35,6 +84,187 @@ afterAll(async () => { const UPDATE = 'application/sparql-update; charset=utf-8'; describe('OwnedManagedHttpClient', () => { + it('accepts request and response bodies at their exact UTF-8 byte limits', async () => { + const client = new OwnedManagedHttpClient('bounded-exact'); + const capacities: number[] = []; + try { + await expect( + client.post(`${base}/exact-boundary`, UPDATE, '€x', 5_000, undefined, { + maxRequestBytes: 4, + maxResponseBytes: 4, + reserveResponseCapacity: (bytes) => capacities.push(bytes), + }), + ).resolves.toEqual({ status: 200, body: '€x' }); + expect(capacities).toEqual([4]); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('preserves the legacy unbounded response path when limits are omitted', async () => { + const client = new OwnedManagedHttpClient('legacy-unbounded'); + try { + const response = await client.post(`${base}/legacy-large`, UPDATE, 'x', 5_000); + expect(Buffer.byteLength(response.body, 'utf8')).toBe(4 * 1024 * 1024 + 1); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('rejects a one-byte-over UTF-8 request before allocation or dispatch', async () => { + const client = new OwnedManagedHttpClient('request-overflow'); + const countBefore = requestCount; + try { + await expect( + client.post(`${base}/`, UPDATE, '€', 5_000, undefined, { + maxRequestBytes: 2, + maxResponseBytes: 16, + }), + ).rejects.toThrow(/request body is 3 bytes; maximum is 2 bytes/); + expect(requestCount).toBe(countBefore); + expect(client.openSocketCount).toBe(0); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('destroys a chunked response immediately when cumulative bytes cross the cap', async () => { + const client = new OwnedManagedHttpClient('response-overflow'); + try { + await expect( + client.post(`${base}/chunked-overflow`, UPDATE, 'x', 5_000, undefined, { + maxRequestBytes: 1, + maxResponseBytes: 5, + }), + ).rejects.toThrow(/response body exceeded 5 bytes/); + + await vi.waitFor(() => expect(overflowClosedBeforeTail).toBe(true)); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + expect(client.openSocketCount).toBe(0); + }); + + it('accepts a chunked response exactly at the cap without concatenating body buffers', async () => { + const client = new OwnedManagedHttpClient('chunked-exact'); + const concatSpy = vi.spyOn(Buffer, 'concat'); + try { + await expect( + client.post(`${base}/chunked-exact`, UPDATE, 'x', 5_000, undefined, { + maxRequestBytes: 1, + maxResponseBytes: 5, + }), + ).resolves.toEqual({ status: 200, body: '12345' }); + expect(concatSpy).not.toHaveBeenCalled(); + } finally { + concatSpy.mockRestore(); + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('charges actual chunked buffer growth instead of reserving the advertised ceiling', async () => { + const client = new OwnedManagedHttpClient('chunked-incremental-capacity'); + const capacities: number[] = []; + try { + await expect( + client.post(`${base}/chunked-exact`, UPDATE, 'x', 5_000, undefined, { + maxRequestBytes: 1, + maxResponseBytes: 4 * 1024 * 1024, + reserveResponseCapacity: (bytes) => capacities.push(bytes), + }), + ).resolves.toEqual({ status: 200, body: '12345' }); + expect(capacities).toEqual([64 * 1024]); + expect(capacities.at(-1)).toBeLessThan(4 * 1024 * 1024); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('rejects a truncated bounded response instead of settling a partial body', async () => { + const client = new OwnedManagedHttpClient('truncated-response'); + try { + await expect( + client.post(`${base}/truncated`, UPDATE, 'x', 5_000, undefined, { + maxRequestBytes: 1, + maxResponseBytes: 5, + }), + ).rejects.toThrow(/response (aborted|closed) before completion/); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('clears its deadline and abort listener after bounded success', async () => { + const client = new OwnedManagedHttpClient('settlement-cleanup'); + const controller = new AbortController(); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + try { + await client.post(`${base}/`, UPDATE, 'x', 4_321, controller.signal, { + maxRequestBytes: 1, + maxResponseBytes: 2, + }); + + const deadlineIndex = setTimeoutSpy.mock.calls.findIndex((call) => call[1] === 4_321); + expect(deadlineIndex).toBeGreaterThanOrEqual(0); + expect(clearTimeoutSpy).toHaveBeenCalledWith( + setTimeoutSpy.mock.results[deadlineIndex]?.value, + ); + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0); + } finally { + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + await client.destroyAndSettle().catch(() => undefined); + } + expect(client.openSocketCount).toBe(0); + }); + + it('clears its deadline when request construction throws synchronously', async () => { + const client = new OwnedManagedHttpClient('construction-cleanup'); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout'); + const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); + try { + await expect( + client.post('ftp://127.0.0.1/', UPDATE, 'x', 4_322, undefined, { + maxRequestBytes: 1, + maxResponseBytes: 2, + }), + ).rejects.toThrow(/Protocol|protocol/); + + const deadlineIndex = setTimeoutSpy.mock.calls.findIndex((call) => call[1] === 4_322); + expect(deadlineIndex).toBeGreaterThanOrEqual(0); + expect(clearTimeoutSpy).toHaveBeenCalledWith( + setTimeoutSpy.mock.results[deadlineIndex]?.value, + ); + expect(client.openSocketCount).toBe(0); + } finally { + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + await client.destroyAndSettle().catch(() => undefined); + } + }); + + it('removes the abort listener and closes its socket after an in-flight abort', async () => { + const client = new OwnedManagedHttpClient('abort-cleanup'); + const controller = new AbortController(); + try { + const pending = client.post(`${base}/hang`, UPDATE, 'x', 5_000, controller.signal, { + maxRequestBytes: 1, + maxResponseBytes: 16, + }); + await vi.waitFor(() => + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(1), + ); + + controller.abort(); + await expect(pending).rejects.toThrow(/aborted/); + expect(getEventListeners(controller.signal, 'abort')).toHaveLength(0); + } finally { + await client.destroyAndSettle().catch(() => undefined); + } + expect(client.openSocketCount).toBe(0); + }); + it('bounds the caller by wall clock, including time spent QUEUED for a socket', async () => { // Regression: `req.setTimeout()` bounds socket-ACTIVE time only. With // maxSockets:1 a queued request was not counted at all, so a call with a diff --git a/packages/storage/test/managed-oxigraph-ownership-v1.test.ts b/packages/storage/test/managed-oxigraph-ownership-v1.test.ts index 16563467f4..2126f7a841 100644 --- a/packages/storage/test/managed-oxigraph-ownership-v1.test.ts +++ b/packages/storage/test/managed-oxigraph-ownership-v1.test.ts @@ -6,9 +6,15 @@ import { createManagedOxigraphOwnershipControllerV1, extractManagedOxigraphLeaseV1, isManagedOxigraphOwnershipLeaseV1, + managedOxigraphOwnershipEndpointsMatchV1, readManagedOxigraphOwnershipSnapshotV1, } from '../src/managed-oxigraph-ownership-v1-internal.js'; +const QUERY_ENDPOINT = 'http://127.0.0.1:7878/query'; +const UPDATE_ENDPOINT = 'http://127.0.0.1:7878/update'; +const createOwnership = () => + createManagedOxigraphOwnershipControllerV1(QUERY_ENDPOINT, UPDATE_ENDPOINT); + /** * Reproduces `resolveAdapterOptions()` in `triple-store.ts`, which is the exact * transform a lease must survive between the daemon and `new SparqlHttpStore()`. @@ -54,7 +60,7 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('cannot survive JSON persistence', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); controller.bindReadyGeneration(); const options = attachManagedOxigraphLeaseV1( { queryEndpoint: 'http://127.0.0.1:7878/query', managedByDkg: true }, @@ -70,7 +76,7 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('cannot be reconstructed by copying, freezing or cloning the handle', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); controller.bindReadyGeneration(); const { lease } = controller; @@ -101,7 +107,7 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('does not let a lease holder assert liveness', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); controller.bindReadyGeneration(); // The lease exposes no mutator; authority lives only on the controller, @@ -113,9 +119,66 @@ describe('managed Oxigraph ownership lease V1', () => { }); }); + describe('supervisor-proven endpoint identity', () => { + it('preserves the B2 zero-argument diagnostic controller without granting B3 endpoint authority', () => { + const controller = createManagedOxigraphOwnershipControllerV1(); + controller.bindReadyGeneration(); + expect(controller.snapshot()).toEqual({ + childGeneration: '1', + ready: true, + terminal: false, + }); + expect(managedOxigraphOwnershipEndpointsMatchV1( + controller.snapshot(), + QUERY_ENDPOINT, + UPDATE_ENDPOINT, + )).toBe(false); + }); + + it('captures one immutable canonical loopback identity for every generation', () => { + const controller = createOwnership(); + const before = controller.snapshot(); + expect(before).toMatchObject({ + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + }); + expect(Object.isFrozen(before)).toBe(true); + + controller.bindReadyGeneration(); + controller.invalidate('child-exit'); + controller.bindReadyGeneration(); + expect(controller.snapshot()).toMatchObject({ + childGeneration: '2', + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + ready: true, + }); + }); + + it.each([ + ['credentials', 'http://user:pass@127.0.0.1:7878/query', UPDATE_ENDPOINT], + ['non-loopback host', 'http://192.0.2.1:7878/query', UPDATE_ENDPOINT], + ['localhost alias', 'http://localhost:7878/query', UPDATE_ENDPOINT], + ['IPv6 alias', 'http://[::1]:7878/query', UPDATE_ENDPOINT], + ['query string', `${QUERY_ENDPOINT}?token=x`, UPDATE_ENDPOINT], + ['fragment', `${QUERY_ENDPOINT}#x`, UPDATE_ENDPOINT], + ['trailing path', `${QUERY_ENDPOINT}/`, UPDATE_ENDPOINT], + ['wrong query path', UPDATE_ENDPOINT, UPDATE_ENDPOINT], + ['wrong update path', QUERY_ENDPOINT, QUERY_ENDPOINT], + ['different port', QUERY_ENDPOINT, 'http://127.0.0.1:7879/update'], + ['non-canonical port', 'http://127.0.0.1:07878/query', UPDATE_ENDPOINT], + ['out-of-range port', 'http://127.0.0.1:65536/query', UPDATE_ENDPOINT], + ['TLS endpoint', 'https://127.0.0.1:7878/query', UPDATE_ENDPOINT], + ])('rejects %s before a lease can be minted', (_label, query, update) => { + expect(() => createManagedOxigraphOwnershipControllerV1(query, update)).toThrow( + /managed Oxigraph|same listener port/, + ); + }); + }); + describe('transport through the adapter factory', () => { it('survives the options spread that erases managedByDkg', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); const generation = controller.bindReadyGeneration(); const daemonOptions = attachManagedOxigraphLeaseV1( @@ -132,13 +195,15 @@ describe('managed Oxigraph ownership lease V1', () => { expect(recovered).toBe(controller.lease); expect(readManagedOxigraphOwnershipSnapshotV1(recovered)).toEqual({ childGeneration: generation, + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, ready: true, terminal: false, }); }); it('does not mutate the caller-supplied config object', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); const original = { queryEndpoint: 'http://127.0.0.1:7878/query' }; const attached = attachManagedOxigraphLeaseV1(original, controller.lease); @@ -156,17 +221,19 @@ describe('managed Oxigraph ownership lease V1', () => { describe('generation lifecycle', () => { it('starts not-ready at generation zero', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); // A spawned-but-unproven child must never satisfy a capability check. expect(controller.snapshot()).toEqual({ childGeneration: '0', + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, ready: false, terminal: false, }); }); it('increments monotonically on every proven-ready bind', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); expect(controller.bindReadyGeneration()).toBe('1'); controller.invalidate('child-exit'); expect(controller.bindReadyGeneration()).toBe('2'); @@ -177,7 +244,7 @@ describe('managed Oxigraph ownership lease V1', () => { it('drops liveness immediately on every recoverable invalidation', () => { for (const reason of ['child-exit', 'child-revive', 'stop', 'listener-ownership-lost'] as const) { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); controller.bindReadyGeneration(); expect(controller.snapshot().ready).toBe(true); @@ -194,7 +261,7 @@ describe('managed Oxigraph ownership lease V1', () => { it('latches terminal and refuses to bind a replacement', () => { for (const reason of ['shutdown', 'port-release-unproven'] as const) { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); controller.bindReadyGeneration(); controller.invalidate(reason); @@ -209,7 +276,7 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('keeps a terminal latch through a later recoverable invalidation', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); controller.bindReadyGeneration(); controller.invalidate('shutdown'); controller.invalidate('child-exit'); @@ -219,7 +286,7 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('shares one live view between the controller and every lease reader', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); const options = resolveAdapterOptionsLike( attachManagedOxigraphLeaseV1({}, controller.lease), ); @@ -232,6 +299,8 @@ describe('managed Oxigraph ownership lease V1', () => { controller.invalidate('listener-ownership-lost'); expect(readManagedOxigraphOwnershipSnapshotV1(held)).toEqual({ childGeneration: '1', + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, ready: false, terminal: false, lastInvalidation: 'listener-ownership-lost', @@ -239,8 +308,8 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('isolates leases from different supervisors', () => { - const a = createManagedOxigraphOwnershipControllerV1(); - const b = createManagedOxigraphOwnershipControllerV1(); + const a = createOwnership(); + const b = createOwnership(); a.bindReadyGeneration(); expect(a.lease).not.toBe(b.lease); @@ -249,7 +318,7 @@ describe('managed Oxigraph ownership lease V1', () => { }); it('returns frozen snapshots that cannot be edited into liveness', () => { - const controller = createManagedOxigraphOwnershipControllerV1(); + const controller = createOwnership(); const snapshot = controller.snapshot(); expect(Object.isFrozen(snapshot)).toBe(true); expect(() => { diff --git a/packages/storage/test/system-record-apply-command-v1.test.ts b/packages/storage/test/system-record-apply-command-v1.test.ts new file mode 100644 index 0000000000..ef25c4f966 --- /dev/null +++ b/packages/storage/test/system-record-apply-command-v1.test.ts @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildSystemRecordConditionalApplyUpdateV1, + mergeSystemRecordOwnedSubjectsV1, +} from '../src/system-record-apply-command-v1-internal.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from '../src/internal-graph-policy.js'; +import { + makeAuthenticActiveReplacementFixtureV1, +} from './helpers/system-record-active-replacement-fixture.js'; +import { + startOxigraphSparqlEndpoint, + type OxigraphSparqlEndpoint, +} from './helpers/oxigraph-sparql-endpoint.js'; + +const subject = (index: number): string => `urn:test:subject:${index.toString().padStart(4, '0')}`; + +describe('system-record conditional apply command V1', () => { + let endpoint: OxigraphSparqlEndpoint | undefined; + afterEach(async () => { + await endpoint?.close(); + endpoint = undefined; + }); + + it('merges prior and next UTF-8 tables once, sorted and duplicate-free', () => { + expect(mergeSystemRecordOwnedSubjectsV1( + [subject(0), subject(2), subject(4)], + [subject(1), subject(2), subject(3)], + )).toEqual([ + subject(0), subject(1), subject(2), subject(3), subject(4), + ]); + }); + + it('accepts an exact 2,048-subject union and rejects 2,049 predispatch', () => { + const exact = Array.from({ length: 2_048 }, (_, index) => subject(index)); + expect(mergeSystemRecordOwnedSubjectsV1(exact.slice(0, 1_024), exact.slice(1_024))) + .toHaveLength(2_048); + + const left = Array.from({ length: 2_048 }, (_, index) => subject(index)); + expect(() => mergeSystemRecordOwnedSubjectsV1(left, [subject(2_048)])) + .toThrow(/2,048/); + }); + + it('accepts only a factory-authentic complete transition and emits one bounded Modify', () => { + const { ready } = makeAuthenticActiveReplacementFixtureV1('shadow'); + const result = buildSystemRecordConditionalApplyUpdateV1(ready); + + expect(result.subjectUnion).toEqual(ready.nextSubjects); + expect(result.requestBytes).toBe(Buffer.byteLength(result.sparql, 'utf8')); + expect(result.sparql.match(/\bDELETE\s*\{/g)).toHaveLength(1); + expect(result.sparql.match(/\bINSERT\s*\{/g)).toHaveLength(1); + expect(result.sparql.match(/\bWHERE\s*\{/g)).toHaveLength(1); + expect(result.sparql).toContain(`GRAPH <${ready.projectionGraph}>`); + expect(result.sparql).toContain(`GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}>`); + expect(result.sparql).not.toMatch(/;|STRSTARTS|COUNT\s*\(|ORDER\s+BY|GRAPH\s+\?g/i); + expect(result.sparql).not.toContain('DROP'); + expect(result.sparql).not.toContain('CLEAR'); + + expect(() => buildSystemRecordConditionalApplyUpdateV1( + structuredClone(ready), + )).toThrow(/not produced by the verified state derivation/); + }); + + it('executes atomically in Oxigraph and a stale CAS becomes a zero-write miss', async () => { + endpoint = await startOxigraphSparqlEndpoint(); + const { ready } = makeAuthenticActiveReplacementFixtureV1('shadow'); + endpoint.store.update(`INSERT DATA { + ${ready.previousReservedQuads.map(renderQuad).join('\n')} + }`); + const update = buildSystemRecordConditionalApplyUpdateV1(ready); + const dispatch = async () => { + const response = await fetch(endpoint!.updateEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/sparql-update; charset=utf-8' }, + body: update.sparql, + }); + expect(response.status).toBe(204); + }; + const ask = (pattern: string): boolean => endpoint!.store.query(`ASK { ${pattern} }`) as boolean; + + await dispatch(); + const projection = ready.nextProjectionQuads[0]; + expect(ask(`GRAPH <${ready.projectionGraph}> { <${projection.subject}> ` + + `<${projection.predicate}> ${renderObject(projection.object)} }`)).toBe(true); + const reserved = ready.nextReservedQuads[0]; + expect(ask(`GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { <${reserved.subject}> ` + + `<${reserved.predicate}> ${renderObject(reserved.object)} }`)).toBe(true); + + const guardedSubject = ready.nextSubjects[0]; + endpoint.store.update(`INSERT DATA { GRAPH <${ready.projectionGraph}> { + <${guardedSubject}> "must-survive-stale-cas" . + } }`); + await dispatch(); + expect(ask(`GRAPH <${ready.projectionGraph}> { <${guardedSubject}> ` + + ' "must-survive-stale-cas" }')).toBe(true); + }); +}); + +function renderQuad(quad: Readonly<{ subject: string; predicate: string; object: string; graph: string }>): string { + return `GRAPH <${quad.graph}> { <${quad.subject}> <${quad.predicate}> ${renderObject(quad.object)} . }`; +} + +function renderObject(value: string): string { + return value.startsWith('"') ? value : `<${value}>`; +} diff --git a/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts b/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts new file mode 100644 index 0000000000..79a8048c23 --- /dev/null +++ b/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts @@ -0,0 +1,888 @@ +import { readFileSync } from 'node:fs'; + +import { + computeKaBundleProjectionDigestV1, + keccak256, + SENTINEL_NO_PRIVATE_V10, + tripleContentV10, + V10MerkleTree, +} from '@origintrail-official/dkg-core'; +import { + buildAgentProfileVerificationClosureV1, + canonicalizeSignedSystemRecordEnvelopeV1, + computeAgentProfileHeadObjectDigestV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordStableKeyHashV1, + digestSystemRecordBytesV1, + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES, + type AgentProfileActiveHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type NetworkIdV1, + type SignedAgentProfileHeadEnvelopeV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { describe, expect, it } from 'vitest'; +import { parseRdfLiteralTerm } from '@origintrail-official/dkg-rdf-utils'; + +import { + createSystemRecordAtomicApplyExecutorV1, + fingerprintSystemRecordProjectionV1, + type SystemRecordAtomicApplyExecutorDepsV1, + type SystemRecordAtomicApplyHttpClientV1, + type SystemRecordAtomicRecoveryRegistrarV1, + type SystemRecordAtomicRecoveryRequestV1, +} from '../src/system-record-atomic-apply-executor-v1-internal.js'; +import { + deriveSystemRecordActiveReplacementV1, +} from '../src/system-record-next-state-v1-internal.js'; +import { parseSystemRecordInspectionResponseV1 } from '../src/system-record-inspection-v1-internal.js'; +import { + decodeSystemRecordAppliedSnapshotV1, +} from '../src/system-record-state-snapshot-v1-internal.js'; +import { + createSystemRecordVerifiedReplacementRegistryV1, + type SystemRecordActiveReplacementIssueV1, +} from '../src/system-record-verified-replacement-v1-internal.js'; +import { SYSTEM_RECORD_V1_PREDICATES, systemRecordEpochSubjectV1 } from '../src/system-record-rdf-schema-v1-internal.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from '../src/internal-graph-policy.js'; +import type { SystemRecordLaneExecutionBindingV1 } from '../src/system-record-materializer-v1.js'; +import type { Quad } from '../src/triple-store.js'; + +interface Vectors { + readonly variants: { readonly active: { readonly object: AgentProfileActiveHeadObjectV1 } }; + readonly signed: { readonly activeEip191: { readonly envelope: SignedAgentProfileHeadEnvelopeV1 } }; +} + +const vectors = JSON.parse(readFileSync(new URL( + '../../core/test/fixtures/system-record-v1/vectors.json', + import.meta.url, +), 'utf8')) as Vectors; + +const VERIFIED = await verifiedFixture( + projectionFor(vectors.variants.active.object.rootSubject), + [vectors.variants.active.object.rootSubject], +); + +const DERIVED_CAPABILITY = + `${vectors.variants.active.object.rootSubject}/.well-known/genid/cap1`; +const VERIFIED_WITH_DERIVED_SUBJECT = await verifiedFixture([ + { + subject: DERIVED_CAPABILITY, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'https://eips.ethereum.org/erc-8004#Capability', + graph: '', + }, + { + subject: vectors.variants.active.object.rootSubject, + predicate: 'https://eips.ethereum.org/erc-8004#capabilities', + object: DERIVED_CAPABILITY, + graph: '', + }, + { + subject: vectors.variants.active.object.rootSubject, + predicate: 'https://schema.org/name', + object: '"Meow"@en', + graph: '', + }, +], [vectors.variants.active.object.rootSubject, DERIVED_CAPABILITY]); + +async function verifiedFixture( + projectionQuads: readonly Readonly[], + ownedSubjectTable: readonly string[], +) { + const source = structuredClone(vectors.variants.active.object); + const canonicalProjectionBytes = canonicalBytesFor(projectionQuads); + const contentDigest = contentDigestFor(projectionQuads); + const bundle = new TextEncoder().encode('verified-profile-bundle'); + const head = { + ...source, + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1( + source.rootSubject, + ownedSubjectTable, + ), + ownedSubjectCount: String(ownedSubjectTable.length), + projectionBytes: String(canonicalProjectionBytes.byteLength), + projectionQuads: String(projectionQuads.length), + contentDigest, + graphScopedAuthorSeal: { + ...source.graphScopedAuthorSeal, + assertionMerkleRoot: contentDigest, + publicTripleCount: String(projectionQuads.length), + }, + bundleDigest: digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + bundle, + ), + } as AgentProfileActiveHeadObjectV1; + return Object.freeze({ + head, + authority: await mintAuthority(head, bundle), + projectionQuads, + canonicalProjectionBytes, + ownedSubjectTable, + }); +} + +const NETWORK = VERIFIED.head.networkId as NetworkIdV1; +const EPOCH: Readonly = Object.freeze({ + subject: systemRecordEpochSubjectV1(NETWORK), + predicate: SYSTEM_RECORD_V1_PREDICATES.materializationEpoch, + object: '"2"', + graph: SYSTEM_RECORD_V1_STATE_GRAPH, +}); + +describe('bounded system-record atomic apply executor V1', () => { + it('synchronously discards an authentic proof refused before executor admission', () => { + const fixture = makeFixture(); + fixture.executor.discard(fixture.proof); + expect(fixture.issueAgain()).toBeDefined(); + expect(() => fixture.executor.discard(fixture.proof)).toThrow(/live and unconsumed/); + }); + + it('classifies a fully exact pre-read as already-applied without an update', async () => { + const fixture = makeFixture({ localState: 'next' }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ + settlement: 'no-mutation', + outcome: { outcome: 'already-applied' }, + }); + expect(fixture.client.updateCalls).toBe(0); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('rejects equal-head projection drift with unchanged byte and quad counts', async () => { + const fixture = makeFixture({ + localState: 'next', + priorProjection: (quads) => quads.map((quad) => ({ + ...quad, + object: quad.object === '"a"' ? '"c"' : quad.object, + })), + }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'deferred', reason: 'validation-mismatch' }, + }); + expect(fixture.client.updateCalls).toBe(0); + }); + + it.each([ + ['missing', (quads: readonly Readonly[]) => quads.slice(1)], + ['extra', (quads: readonly Readonly[]) => [...quads, { + subject: VERIFIED.head.rootSubject, + predicate: 'https://schema.org/url', + object: 'https://example.com/profile', + graph: quads[0]!.graph, + }]], + ] as const)('rejects a %s row in the committed local projection', async (_label, mutate) => { + const fixture = makeFixture({ localState: 'next', priorProjection: mutate }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'deferred', reason: 'validation-mismatch' }, + }); + expect(fixture.client.updateCalls).toBe(0); + }); + + it('rejects a pre-existing row on a next-only subject when local state is absent', async () => { + const fixture = makeFixture({ + priorProjection: () => [{ + subject: VERIFIED.head.rootSubject, + predicate: 'https://schema.org/name', + object: '"pre-existing"', + graph: '', + }], + }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'deferred', reason: 'validation-mismatch' }, + }); + expect(fixture.client.updateCalls).toBe(0); + }); + + it('fingerprints a maximum-row projection incrementally in canonical order', () => { + const quads = Array.from({ length: 10_000 }, (_, index) => ({ + subject: VERIFIED.head.rootSubject, + predicate: `https://example.com/p/${String(index).padStart(5, '0')}`, + object: '"v"', + graph: 'urn:ignored-by-projection-digest', + })); + const canonicalBytes = canonicalBytesFor(quads); + expect(fingerprintSystemRecordProjectionV1(quads)).toEqual({ + digest: computeKaBundleProjectionDigestV1(canonicalBytes), + bytes: String(canonicalBytes.byteLength), + quads: '10000', + }); + expect(() => fingerprintSystemRecordProjectionV1([...VERIFIED.projectionQuads].reverse())) + .toThrow(/canonical line order/); + }); + + it('defers with zero dispatch when admitted work consumes the apply budget', async () => { + const fixture = makeFixture({ admittedDeadlineMs: 1_499 }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'deferred', reason: 'insufficient-apply-budget' }, + }); + expect(fixture.client.updateCalls).toBe(0); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it.each([ + ['cross-session', { sessionIdentity: Object.freeze(Object.create(null) as object) }], + ['stale-generation', { activationGeneration: '9', childGeneration: '10' }], + ] as const)( + 'discards an authentic %s proof rejected before admission', + async (_label, stale) => { + const fixture = makeFixture(); + const result = await fixture.executor.execute( + fixture.proof, + Object.freeze({ ...fixture.binding, ...stale }), + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'capability-lost' }, + }); + expect(fixture.client.calls).toHaveLength(0); + expect(fixture.issueAgain()).toBeDefined(); + }, + ); + + it('refuses to discard a consumed handle when deadline inspection rejects it', async () => { + const fixture = makeFixture(); + const facts = fixture.consumeProof(); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'capability-lost' }, + }); + expect(() => fixture.issueAgain()).toThrow(/reservation is already live/); + + fixture.releaseFacts(facts); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('discards an unconsumed proof when scheduler admission fails before start', async () => { + const scheduler: NonNullable = { + async run(): Promise { + throw new Error('scheduler admission failed'); + }, + }; + const fixture = makeFixture({ scheduler }); + await expect(fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + )).rejects.toThrow(/scheduler admission failed/); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('discards a proof whose generation becomes stale after scheduler admission', async () => { + let executionBinding!: SystemRecordLaneExecutionBindingV1; + const scheduler: NonNullable = { + async run(_priority, _operation, work): Promise { + (executionBinding as { childGeneration: string }).childGeneration = '3'; + return work(); + }, + }; + const fixture = makeFixture({ scheduler }); + executionBinding = { ...fixture.binding }; + const result = await fixture.executor.execute( + fixture.proof, + executionBinding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'capability-lost' }, + }); + expect(fixture.client.calls).toHaveLength(0); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('issues one update and requires full state, claims, receipt and projection post-read', async () => { + const fixture = makeFixture({ postState: 'next' }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ settlement: 'settled', outcome: { outcome: 'applied' } }); + expect(fixture.client.updateCalls).toBe(1); + expect(fixture.client.calls.filter((call) => + call.contentType.startsWith('application/sparql-update'))).toHaveLength(1); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('settles an active projection whose derived subject precedes its root in canonical line order', async () => { + const fixture = makeFixture({ + verified: VERIFIED_WITH_DERIVED_SUBJECT, + postState: 'next', + }); + expect(fixture.exactNextProjection.map((quad) => quad.subject)).toEqual([ + DERIVED_CAPABILITY, + VERIFIED_WITH_DERIVED_SUBJECT.head.rootSubject, + VERIFIED_WITH_DERIVED_SUBJECT.head.rootSubject, + ]); + expect(parseSystemRecordInspectionResponseV1({ + body: selectJson(fixture.exactNextProjection), + scope: 'authoritative', + allowedSubjects: VERIFIED_WITH_DERIVED_SUBJECT.ownedSubjectTable, + maxRows: fixture.exactNextProjection.length, + })).toEqual(fixture.exactNextProjection); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ settlement: 'settled', outcome: { outcome: 'applied' } }); + expect(fixture.client.updateCalls).toBe(1); + }); + + it('exact-recovers a derived-subject projection in a replacement generation', async () => { + const recoveryCompletion = new Promise<{ readonly resolution: 'unavailable' }>(() => undefined); + const fixture = makeFixture({ + verified: VERIFIED_WITH_DERIVED_SUBJECT, + postState: 'malformed', + recoveryCompletion, + }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ settlement: 'recovery-owned' }); + + const responses = [ + { status: 200, body: selectJson(fixture.exactNextReserved) }, + { status: 200, body: selectJson(fixture.exactNextProjection) }, + ]; + const recoveryClient: SystemRecordAtomicApplyHttpClientV1 = { + childGeneration: '3', + isDestroyed: false, + post: async (_url, _contentType, _body, _timeoutMs, _signal, limits) => { + const response = responses.shift(); + if (!response) throw new Error('unexpected exact-recovery request'); + limits?.reserveResponseCapacity?.(Buffer.byteLength(response.body, 'utf8')); + return response; + }, + }; + const abort = new AbortController(); + await expect(fixture.registeredRequest()!.reconcile({ + client: recoveryClient, + queryEndpoint: 'http://127.0.0.1:7878/query', + absoluteDeadlineMs: performance.now() + 30_000, + signal: abort.signal, + assertAttributable: () => true, + })).resolves.toMatchObject({ resolution: 'applied' }); + expect(responses).toHaveLength(0); + }); + + it('precharges response text and replaces post-read capacity with its exact prepared weight', async () => { + const fixture = makeFixture({ postState: 'next' }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result.settlement).toBe('settled'); + + const firstDispatch = fixture.accountingEvents.findIndex((event) => event === 'dispatch'); + const firstResponseBytes = fixture.client.calls[0]?.responseBytes; + expect(firstResponseBytes).toBeGreaterThan(0); + expect(fixture.accountingEvents[firstDispatch - 1]).toBe( + `response:${(firstResponseBytes as number) * 3}`, + ); + expect(fixture.accountingEvents).toContain(`response:${(firstResponseBytes as number) * 2}`); + expect(fixture.accountingEvents).toContain('response:0'); + + const prepared = fixture.accountingEvents + .filter((event) => event.startsWith('prepared:')) + .map((event) => Number(event.slice('prepared:'.length))); + expect(prepared.length).toBeGreaterThan(6); + expect(prepared.every((bytes) => bytes <= SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES)).toBe(true); + const update = fixture.client.calls.find((call) => + call.contentType.startsWith('application/sparql-update')); + expect(update).toBeDefined(); + const updateBytes = Buffer.byteLength(update!.body, 'utf8'); + expect(prepared).toContain(updateBytes * 3); + expect(prepared).toContain(updateBytes * 2); + }); + + it('transfers to recovery when generation attribution is lost on the final post-read', async () => { + const fixture = makeFixture({ + postState: 'next', + loseAttributionOnFinalResponse: true, + }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ + settlement: 'recovery-owned', + outcome: { outcome: 'indeterminate' }, + }); + expect(fixture.registeredRequest()).toBeDefined(); + }); + + it('returns a no-mutation CAS miss when the exact prior state survives', async () => { + const fixture = makeFixture({ postState: 'prior' }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'deferred', reason: 'state-changed' }, + }); + expect(fixture.client.updateCalls).toBe(1); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('registers recovery before the exclusive permit releases on uncertainty', async () => { + const order: string[] = []; + const fixture = makeFixture({ postState: 'malformed', order }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ + settlement: 'recovery-owned', + outcome: { outcome: 'indeterminate', recoveryGeneration: '3' }, + }); + expect(order).toEqual(['exclusive-start', 'recovery-registered', 'exclusive-release']); + expect(result.settlement === 'recovery-owned' && result.recovery.ownership) + .toBe(fixture.registeredOwnership()); + await Promise.resolve(); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('throws rather than returning a bare uncertain result when ownership is refused', async () => { + const fixture = makeFixture({ postState: 'malformed', rejectRecoveryOwnership: true }); + await expect(fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + )).rejects.toThrow(/did not accept the exact ownership token/); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it.each(['queued', 'held'] as const)( + 'aborts %s admission at the issuer deadline without dispatching or leaking the lease', + async () => { + const scheduler: NonNullable = { + async run(_priority, _operation, _work, signal): Promise { + return await new Promise((_resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason); + return; + } + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }; + const fixture = makeFixture({ + admittedDeadlineMs: Math.ceil(performance.now() + 10), + now: () => performance.now(), + scheduler, + }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toEqual({ + settlement: 'no-mutation', + outcome: { outcome: 'deferred', reason: 'aborted' }, + }); + expect(fixture.client.calls).toHaveLength(0); + expect(() => fixture.inspectProof()).toThrow(/no longer live/); + expect(fixture.issueAgain()).toBeDefined(); + }, + ); + + it('transfers the reservation to recovery and releases only after terminal completion', async () => { + let settleRecovery!: () => void; + const recoveryCompletion = new Promise<{ + readonly resolution: 'unavailable'; + }>((resolve) => { + settleRecovery = () => resolve(Object.freeze({ resolution: 'unavailable' as const })); + }); + const fixture = makeFixture({ postState: 'malformed', recoveryCompletion }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result.settlement).toBe('recovery-owned'); + expect(() => fixture.issueAgain()).toThrow(/reservation is already live/); + settleRecovery(); + await recoveryCompletion; + await Promise.resolve(); + expect(fixture.issueAgain()).toBeDefined(); + }); + + it('threads lifecycle cancellation into the exact recovery HTTP read and joins its abort', async () => { + let settleRecovery!: () => void; + const recoveryCompletion = new Promise<{ + readonly resolution: 'unavailable'; + }>((resolve) => { + settleRecovery = () => resolve(Object.freeze({ resolution: 'unavailable' as const })); + }); + const fixture = makeFixture({ postState: 'malformed', recoveryCompletion }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result.settlement).toBe('recovery-owned'); + + const abort = new AbortController(); + let observedSignal: AbortSignal | undefined; + const recoveryClient: SystemRecordAtomicApplyHttpClientV1 = { + childGeneration: '3', + isDestroyed: false, + post: async (_url, _contentType, _body, _timeoutMs, signal) => { + observedSignal = signal; + return await new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }; + const reconcile = fixture.registeredRequest()!.reconcile({ + client: recoveryClient, + queryEndpoint: 'http://127.0.0.1:7878/query', + absoluteDeadlineMs: performance.now() + 30_000, + signal: abort.signal, + assertAttributable: () => true, + }); + await Promise.resolve(); + expect(observedSignal).toBe(abort.signal); + abort.abort(new Error('shutdown')); + await expect(reconcile).resolves.toEqual({ resolution: 'unavailable' }); + + settleRecovery(); + await recoveryCompletion; + }); +}); + +function makeFixture(options: Readonly<{ + verified?: typeof VERIFIED; + admittedDeadlineMs?: number; + localState?: 'absent' | 'next'; + postState?: 'next' | 'prior' | 'malformed'; + rejectRecoveryOwnership?: boolean; + order?: string[]; + recoveryCompletion?: Promise<{ readonly resolution: 'unavailable' }>; + now?: () => number; + scheduler?: NonNullable; + loseAttributionOnFinalResponse?: boolean; + priorProjection?: ( + projection: readonly Readonly[], + ) => readonly Readonly[]; +}> = {}) { + const verified = options.verified ?? VERIFIED; + const order = options.order ?? []; + const accountingEvents: string[] = []; + const admittedDeadlineMs = options.admittedDeadlineMs ?? 10_000; + const binding = Object.freeze({ + activationGeneration: '1', + networkId: NETWORK, + kind: 'agents', + mode: 'authoritative', + sessionIdentity: Object.freeze(Object.create(null) as object), + childGeneration: '2', + materializationEpoch: '2', + }) satisfies SystemRecordLaneExecutionBindingV1; + + // A second issued handle supplies factory-authentic facts for calculating + // the fake store's exact expected response. The executor receives a distinct + // one-shot handle from the same verifier fixture. + const expectedRegistry = createSystemRecordVerifiedReplacementRegistryV1(); + const expectedIssue = issue(binding, admittedDeadlineMs, verified); + const expectedFacts = expectedRegistry.consumer.consume( + expectedRegistry.issuer.issueActive(expectedIssue), + binding, + ); + const absentSnapshot = decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: computeStableKey(), + materializationEpoch: '2', + quads: [EPOCH], + }); + const ready = deriveSystemRecordActiveReplacementV1({ + facts: expectedFacts, + snapshot: absentSnapshot, + observedRootClaimQuads: [], + }); + if (ready.outcome !== 'ready') throw new Error(`fixture derivation was ${ready.outcome}`); + + const localNext = options.localState === 'next'; + const nextRootSubjects = new Set(ready.next.rootClaimQuads.map((quad) => quad.subject)); + const initialReserved = localNext + ? ready.nextReservedQuads.filter((quad) => !nextRootSubjects.has(quad.subject)) + : [EPOCH]; + const initialRoots = localNext ? ready.next.rootClaimQuads : []; + const faithfulInitialProjection = localNext + ? ready.nextProjectionQuads.map((quad) => ({ ...quad, graph: ready.projectionGraph })) + : []; + const initialProjection = options.priorProjection?.(faithfulInitialProjection) + ?? faithfulInitialProjection; + const responses: Array> = [ + { status: 200, body: selectJson(initialReserved) }, + { status: 200, body: selectJson(initialRoots) }, + { status: 200, body: selectJson(initialProjection) }, + ]; + if (!localNext && admittedDeadlineMs >= 1_500) { + responses.push({ status: 204, body: '' }); + if (options.postState === 'malformed') { + responses.push({ status: 200, body: '{' }); + } else if (options.postState === 'prior') { + responses.push( + { status: 200, body: selectJson([EPOCH]) }, + { status: 200, body: selectJson([]) }, + ); + } else { + responses.push( + { status: 200, body: selectJson(ready.nextReservedQuads) }, + { status: 200, body: selectJson(ready.nextProjectionQuads.map((quad) => ({ + ...quad, + graph: ready.projectionGraph, + }))) }, + ); + } + } + + let attributable = true; + const client = new FakeClient( + responses, + () => accountingEvents.push('dispatch'), + (remainingResponses) => { + if (options.loseAttributionOnFinalResponse && remainingResponses === 0) { + attributable = false; + } + }, + ); + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const proof = registry.issuer.issueActive(issue(binding, admittedDeadlineMs, verified)); + const accountedConsumer: typeof registry.consumer = Object.freeze({ + ...registry.consumer, + replaceCharge: (facts, category, bytes) => { + registry.consumer.replaceCharge(facts, category, bytes); + accountingEvents.push(`${category}:${bytes}`); + }, + }); + let registeredOwnership: object | undefined; + let registeredRequest: SystemRecordAtomicRecoveryRequestV1 | undefined; + const registerRecovery: SystemRecordAtomicRecoveryRegistrarV1 = (request) => { + order.push('recovery-registered'); + registeredOwnership = request.ownership; + registeredRequest = request; + return Object.freeze({ + ownership: options.rejectRecoveryOwnership + ? Object.freeze(Object.create(null) as object) + : request.ownership, + recoveryGeneration: '3', + completion: options.recoveryCompletion + ?? Promise.resolve(Object.freeze({ resolution: 'unavailable' as const })), + }); + }; + const defaultScheduler: NonNullable = { + async run(_priority, _operation, work) { + order.push('exclusive-start'); + try { + return await work(); + } finally { + order.push('exclusive-release'); + } + }, + }; + const executor = createSystemRecordAtomicApplyExecutorV1({ + consumer: accountedConsumer, + storeId: Object.freeze(Object.create(null) as object), + queryEndpoint: 'http://127.0.0.1:7878/query', + updateEndpoint: 'http://127.0.0.1:7878/update', + resolveClient: () => attributable ? client : null, + now: options.now ?? (() => 0), + scheduler: options.scheduler ?? defaultScheduler, + }); + return { + executor, + proof, + binding, + client, + accountingEvents, + registerRecovery, + registeredOwnership: () => registeredOwnership, + registeredRequest: () => registeredRequest, + exactNextReserved: ready.nextReservedQuads, + exactNextProjection: ready.nextProjectionQuads.map((quad) => ({ + ...quad, + graph: ready.projectionGraph, + })), + inspectProof: () => registry.consumer.inspectDeadline(proof, binding), + consumeProof: () => registry.consumer.consume(proof, binding), + releaseFacts: (facts: unknown) => registry.consumer.release(facts), + issueAgain: () => registry.issuer.issueActive(issue(binding, admittedDeadlineMs, verified)), + }; +} + +function issue( + binding: SystemRecordLaneExecutionBindingV1, + admittedDeadlineMs: number, + verified = VERIFIED, +): SystemRecordActiveReplacementIssueV1 { + return { + ...binding, + networkId: NETWORK, + admittedDeadlineMs, + head: structuredClone(verified.head), + verifiedAuthoritySummary: verified.authority, + canonicalProjectionBytes: new Uint8Array(verified.canonicalProjectionBytes), + projectionQuads: structuredClone(verified.projectionQuads), + ownedSubjectTable: verified.ownedSubjectTable, + }; +} + +function computeStableKey() { + return computeSystemRecordStableKeyHashV1(NETWORK, VERIFIED.head.peerId); +} + +class FakeClient implements SystemRecordAtomicApplyHttpClientV1 { + readonly childGeneration = '2'; + readonly isDestroyed = false; + readonly calls: Array> = []; + updateCalls = 0; + + constructor( + private readonly responses: Array>, + private readonly onPost: () => void = () => undefined, + private readonly onResponse: (remainingResponses: number) => void = () => undefined, + ) {} + + async post( + _url: string, + contentType: string, + body: string, + _timeoutMs: number, + _signal?: AbortSignal, + limits?: Parameters[5], + ) { + const response = this.responses.shift(); + if (response === undefined) throw new Error('unexpected fake request'); + const responseBytes = Buffer.byteLength(response.body, 'utf8'); + limits?.reserveResponseCapacity?.(responseBytes); + this.onPost(); + this.calls.push({ contentType, body, responseBytes }); + if (contentType.startsWith('application/sparql-update')) this.updateCalls += 1; + this.onResponse(this.responses.length); + return response; + } +} + +function projectionFor(root: string) { + return [ + { subject: root, predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', object: 'https://dkg.network/ontology#Agent', graph: '' }, + { subject: root, predicate: 'https://schema.org/description', object: '"b"', graph: '' }, + { subject: root, predicate: 'https://schema.org/name', object: '"a"', graph: '' }, + ] as const; +} + +function canonicalBytesFor(quads: readonly Readonly<{ subject: string; predicate: string; object: string }>[]) { + return new TextEncoder().encode(quads.map((quad) => + `${new TextDecoder().decode(tripleContentV10(quad.subject, quad.predicate, quad.object))}\n`).join('')); +} + +function contentDigestFor(quads: readonly Readonly<{ subject: string; predicate: string; object: string }>[]) { + const leaves = quads.map((quad) => keccak256(tripleContentV10(quad.subject, quad.predicate, quad.object))); + return `0x${Buffer.from(V10MerkleTree.computeKARoot( + new V10MerkleTree(leaves).root, + SENTINEL_NO_PRIVATE_V10, + )).toString('hex')}` as const; +} + +async function mintAuthority( + head: AgentProfileActiveHeadObjectV1, + bundle: Uint8Array, +): Promise { + const envelope = { + ...structuredClone(vectors.signed.activeEip191.envelope), + object: head, + objectDigest: computeAgentProfileHeadObjectDigestV1(head), + } as SignedAgentProfileHeadEnvelopeV1; + const artifacts = new Map([ + [`agent-profile-head:${envelope.objectDigest}`, { + objectKind: 'agent-profile-head' as const, + digest: envelope.objectDigest, + canonicalBytes: canonicalizeSignedSystemRecordEnvelopeV1(envelope), + }], + [`profile-bundle:${head.bundleDigest}`, { + objectKind: 'profile-bundle' as const, + digest: head.bundleDigest, + canonicalBytes: bundle, + }], + ]); + const closure = await buildAgentProfileVerificationClosureV1(envelope.objectDigest, { + nowMs: Date.parse('2026-08-05T12:10:00Z'), + resolve: async (reference) => artifacts.get(`${reference.objectKind}:${reference.digest}`), + verifyAuthorityEnvelope: () => true, + verifyCurrentBundle: (_head, bytes) => Buffer.from(bytes).equals(Buffer.from(bundle)), + }); + return closure.authoritySummary; +} + +function selectJson(quads: readonly Readonly[]): string { + return JSON.stringify({ + head: { vars: ['s', 'p', 'o'] }, + results: { bindings: quads.map((quad) => ({ + s: { type: 'uri', value: quad.subject }, + p: { type: 'uri', value: quad.predicate }, + o: objectBinding(quad.object), + })) }, + }); +} + +function objectBinding(value: string): Readonly> { + if (!value.startsWith('"')) return { type: 'uri', value }; + const parsed = parseRdfLiteralTerm(value); + if (parsed === null) throw new Error(`unsupported test literal ${value}`); + return Object.freeze({ + type: 'literal', + value: parsed.value, + ...(parsed.kind === 'typed' ? { datatype: parsed.datatype } : {}), + ...(parsed.kind === 'language' ? { 'xml:lang': parsed.language } : {}), + }); +} diff --git a/packages/storage/test/system-record-capability-discovery-v1.test.ts b/packages/storage/test/system-record-capability-discovery-v1.test.ts index 0a606fcbd4..32a842e373 100644 --- a/packages/storage/test/system-record-capability-discovery-v1.test.ts +++ b/packages/storage/test/system-record-capability-discovery-v1.test.ts @@ -14,7 +14,8 @@ import { import { __resetSystemRecordControllerRegistrationForTests } from '../src/system-record-materializer-v1.js'; import { createTripleStore, type TripleStore } from '../src/triple-store.js'; -const ENDPOINT = 'http://127.0.0.1:1/query'; +const QUERY_ENDPOINT = 'http://127.0.0.1:1/query'; +const UPDATE_ENDPOINT = 'http://127.0.0.1:1/update'; const noopHandoff: ManagedOxigraphSupervisorHandoffV1 = { stopAndProveOwnedChildDead: async () => undefined, @@ -26,7 +27,11 @@ describe('system-record V1 capability discovery', () => { const managedOptions = (opts: { handoff?: boolean } = {}) => attachManagedOxigraphLeaseV1( - { queryEndpoint: ENDPOINT, managedByDkg: true }, + { + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + managedByDkg: true, + }, ownership.lease, opts.handoff === false ? undefined : noopHandoff, ); @@ -44,7 +49,7 @@ describe('system-record V1 capability discovery', () => { beforeEach(() => { __resetSystemRecordControllerRegistrationForTests(); - ownership = createManagedOxigraphOwnershipControllerV1(); + ownership = createManagedOxigraphOwnershipControllerV1(QUERY_ENDPOINT, UPDATE_ENDPOINT); ownership.bindReadyGeneration(); }); @@ -54,14 +59,14 @@ describe('system-record V1 capability discovery', () => { describe('fail-closed preconditions', () => { it('is absent on an ordinary operator-configured endpoint', async () => { - const store = await build({ queryEndpoint: ENDPOINT }); + const store = await build({ queryEndpoint: QUERY_ENDPOINT }); expect(store.getSystemRecordLaneControllerV1?.()).toBeUndefined(); await store.close().catch(() => undefined); }); it('is absent for config booleans alone, however generous', async () => { const store = await build({ - queryEndpoint: ENDPOINT, + queryEndpoint: QUERY_ENDPOINT, managedByDkg: true, atomicUpdates: true, }); @@ -89,6 +94,31 @@ describe('system-record V1 capability discovery', () => { expect(store.getSystemRecordLaneControllerV1?.()).toBeDefined(); await store.close().catch(() => undefined); }); + + it.each([ + ['wrong query path', UPDATE_ENDPOINT, UPDATE_ENDPOINT, undefined], + ['wrong update path', QUERY_ENDPOINT, QUERY_ENDPOINT, undefined], + ['different port', QUERY_ENDPOINT, 'http://127.0.0.1:2/update', undefined], + ['localhost alias', 'http://localhost:1/query', UPDATE_ENDPOINT, undefined], + ['credentials in URL', 'http://user:pass@127.0.0.1:1/query', UPDATE_ENDPOINT, undefined], + ['query string', `${QUERY_ENDPOINT}?x=1`, UPDATE_ENDPOINT, undefined], + ['fragment', `${QUERY_ENDPOINT}#x`, UPDATE_ENDPOINT, undefined], + ['trailing slash', `${QUERY_ENDPOINT}/`, UPDATE_ENDPOINT, undefined], + ['authorization option', QUERY_ENDPOINT, UPDATE_ENDPOINT, 'Bearer secret'], + ])('is absent when adapter identity has %s', async (_label, queryEndpoint, updateEndpoint, auth) => { + const options = attachManagedOxigraphLeaseV1( + { + queryEndpoint, + updateEndpoint, + ...(auth === undefined ? {} : { auth }), + }, + ownership.lease, + noopHandoff, + ); + const store = await build(options); + expect(store.getSystemRecordLaneControllerV1?.()).toBeUndefined(); + await store.close().catch(() => undefined); + }); }); describe('through the production decorator stack', () => { @@ -115,6 +145,30 @@ describe('system-record V1 capability discovery', () => { await store.close().catch(() => undefined); }); + it('releases a passively discovered controller when its store closes', async () => { + const firstStore = await build(managedOptions()); + const retired = firstStore.getSystemRecordLaneControllerV1?.(); + expect(retired).toBeDefined(); + await firstStore.close(); + + // SparqlHttpStore.close rotates a reusable lifecycle generation. The + // passive controller must follow that contract rather than leaving this + // same store memoized to the terminal object it just released. + const reprobed = firstStore.getSystemRecordLaneControllerV1?.(); + expect(reprobed).toBeDefined(); + expect(reprobed).not.toBe(retired); + await firstStore.close(); + + const replacementStore = await build(managedOptions()); + expect(replacementStore.getSystemRecordLaneControllerV1?.()).toBeDefined(); + await expect(retired!.open({ + networkId: 'testnet', + kinds: ['agents'], + mode: 'shadow', + })).rejects.toThrow(/terminal/); + await replacementStore.close(); + }); + it('is DENIED by an enabled changelog', async () => { // Its marker append is a second transaction, so it cannot represent the // lane's single-durability-unit contract. diff --git a/packages/storage/test/system-record-control-barrier-integration-v1.test.ts b/packages/storage/test/system-record-control-barrier-integration-v1.test.ts index 2bf369abd9..f9dcf915e5 100644 --- a/packages/storage/test/system-record-control-barrier-integration-v1.test.ts +++ b/packages/storage/test/system-record-control-barrier-integration-v1.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; // Adapter registration is a side effect of importing the module (see // `system-record-capability-discovery-v1.test.ts`). @@ -16,7 +17,10 @@ import { } from '../src/system-record-materializer-v1.js'; import { createTripleStore, type TripleStore } from '../src/triple-store.js'; -const ENDPOINT = 'http://oxigraph-barrier.test/query'; +let QUERY_ENDPOINT: string; +let UPDATE_ENDPOINT: string; +let managedServer: Server; +let epoch: string | null; const ACTIVATION: SystemRecordLaneActivationV1 = { networkId: 'testnet', @@ -61,12 +65,15 @@ class GatedFetch { /** Records whether — and when — the supervisor was asked to touch the child. */ class RecordingSupervisor implements ManagedOxigraphSupervisorHandoffV1 { readonly calls: string[] = []; + failAt: 'stop' | 'start' | null = null; stopAndProveOwnedChildDead = async (): Promise => { this.calls.push('stop'); + if (this.failAt === 'stop') throw new Error('supervisor stop failed'); }; startAndProveCleanGeneration = async (): Promise => { this.calls.push('start'); + if (this.failAt === 'start') throw new Error('supervisor start failed'); }; } @@ -96,19 +103,57 @@ describe('system-record lane control barrier (real adapter + scheduler)', () => let gated: GatedFetch; let store: TripleStore; + beforeAll(async () => { + managedServer = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (req.url === '/query') { + res.writeHead(200, { 'Content-Type': 'application/sparql-results+json' }); + res.end(JSON.stringify({ + head: { vars: ['epoch'] }, + results: { + bindings: epoch === null ? [] : [{ epoch: { type: 'literal', value: epoch } }], + }, + })); + return; + } + epoch = /INSERT[\s\S]*?materialization-epoch> "([0-9]+)"/u.exec(body)?.[1] ?? null; + res.writeHead(epoch === null ? 400 : 204); + res.end(); + }); + }); + await new Promise((resolve) => managedServer.listen(0, '127.0.0.1', resolve)); + const address = managedServer.address(); + if (address === null || typeof address === 'string') throw new Error('test server has no port'); + QUERY_ENDPOINT = `http://127.0.0.1:${address.port}/query`; + UPDATE_ENDPOINT = `http://127.0.0.1:${address.port}/update`; + }); + + afterAll(async () => { + await new Promise((resolve) => managedServer.close(() => resolve())); + }); + beforeEach(async () => { __resetSystemRecordControllerRegistrationForTests(); - ownership = createManagedOxigraphOwnershipControllerV1(); + ownership = createManagedOxigraphOwnershipControllerV1(QUERY_ENDPOINT, UPDATE_ENDPOINT); ownership.bindReadyGeneration(); supervisor = new RecordingSupervisor(); gated = new GatedFetch(); + epoch = null; store = await createTripleStore({ backend: 'sparql-http', options: attachManagedOxigraphLeaseV1( // A long transport timeout: the point of the held request is that it is // still in flight when the transition asks for the store, so it must not // be cut short by the adapter's own deadline. - { queryEndpoint: ENDPOINT, managedByDkg: true, timeout: 60_000 }, + { + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + managedByDkg: true, + timeout: 60_000, + }, ownership.lease, supervisor, ) as unknown as Record, @@ -182,4 +227,42 @@ describe('system-record lane control barrier (real adapter + scheduler)', () => await expect(queuedDuringSection).resolves.toBeDefined(); expect(supervisor.calls).toEqual(['stop', 'start']); }); + + it('permanently fails ordinary managed mutations closed after a transition fault', async () => { + const controller = store.getSystemRecordLaneControllerV1?.(); + supervisor.failAt = 'start'; + + await expect(controller!.open(ACTIVATION)).rejects.toThrow(/supervisor start failed/); + await expect(store.insert([{ + subject: 'urn:test:s', + predicate: 'urn:test:p', + object: '"o"', + graph: 'urn:test:g', + }])).rejects.toMatchObject({ + code: 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE', + }); + }); + + it('disposes an opened controller on store close and releases registration once', async () => { + const controller = store.getSystemRecordLaneControllerV1?.(); + await controller!.open(ACTIVATION); + supervisor.calls.length = 0; + + await store.close(); + expect(supervisor.calls).toEqual(['stop']); + + // The active controller, not just a passive capability probe, released the + // process-global slot. The fake supervisor keeps the lease ready so this + // assertion isolates registration disposal from real process teardown. + store = await createTripleStore({ + backend: 'sparql-http', + options: attachManagedOxigraphLeaseV1({ + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + managedByDkg: true, + }, ownership.lease, supervisor) as unknown as Record, + graphSetIndex: false, + }); + expect(store.getSystemRecordLaneControllerV1?.()).toBeDefined(); + }); }); diff --git a/packages/storage/test/system-record-inspection-v1.test.ts b/packages/storage/test/system-record-inspection-v1.test.ts new file mode 100644 index 0000000000..e258f03e63 --- /dev/null +++ b/packages/storage/test/system-record-inspection-v1.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH } from '@origintrail-official/dkg-core/system-record-v1'; + +import { + SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1, + SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1, + buildSystemRecordProjectionInspectionQueryV1, + buildSystemRecordReservedInspectionQueryV1, + estimateSystemRecordInspectionParseBytesV1, + parseSystemRecordInspectionResponseV1, + retainedSystemRecordInspectionQuadsBytesV1, +} from '../src/system-record-inspection-v1-internal.js'; +import { + SYSTEM_RECORD_V1_STATE_GRAPH, + SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH, +} from '../src/internal-graph-policy.js'; + +const S1 = 'urn:test:subject:1'; +const S2 = 'urn:test:subject:2'; +const P = 'urn:test:predicate'; +const G = SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH; + +describe('system-record bounded inspection', () => { + it('builds indexed exact-subject queries with cap+1 and no scan/sort constructs', () => { + const reserved = buildSystemRecordReservedInspectionQueryV1([S2, S1]); + expect(reserved).toContain(`GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}>`); + expect(reserved).toContain(`VALUES ?s { <${S1}> <${S2}> }`); + expect(reserved).toContain(`LIMIT ${SYSTEM_RECORD_MAX_RESERVED_INSPECTION_ROWS_V1 + 1}`); + + const projection = buildSystemRecordProjectionInspectionQueryV1('shadow', [S1]); + expect(projection).toContain(`LIMIT ${SYSTEM_RECORD_MAX_PROJECTION_INSPECTION_ROWS_V1}`); + for (const banned of ['ORDER BY', 'COUNT(', 'STRSTARTS', 'GRAPH ?g', 'CLEAR ', 'DROP ']) { + expect(`${reserved}\n${projection}`).not.toContain(banned); + } + expect(() => buildSystemRecordProjectionInspectionQueryV1('shadow', [])).toThrow(/subject count/); + expect(() => buildSystemRecordProjectionInspectionQueryV1('shadow', [S1, S1])).toThrow(/unique/); + expect(() => buildSystemRecordProjectionInspectionQueryV1( + 'caller-authored' as 'shadow', [S1], + )).toThrow(/mode/); + }); + + it('charges one exact inspection-query buffer and its retained string without map/join copies', () => { + const charges: number[] = []; + const query = buildSystemRecordProjectionInspectionQueryV1( + 'shadow', + [S2, S1], + (bytes) => charges.push(bytes), + ); + const encodedBytes = Buffer.byteLength(query, 'utf8'); + expect(charges).toEqual([encodedBytes * 3, encodedBytes * 2]); + expect(query).toContain(`VALUES ?s { <${S1}> <${S2}> }`); + }); + + it('strictly decodes, canonicalizes, sorts and freezes URI/literal rows', () => { + const body = response([ + row(S2, P, { type: 'literal', value: 'line\n"quoted"', datatype: 'urn:test:type' }), + row(S1, P, { type: 'uri', value: 'urn:test:object' }), + ]); + const parsed = parseSystemRecordInspectionResponseV1({ + body, scope: 'shadow', allowedSubjects: [S1, S2], maxRows: 2, + }); + expect(parsed).toEqual([ + { subject: S1, predicate: P, object: 'urn:test:object', graph: G }, + { + subject: S2, + predicate: P, + object: '"line\\n\\"quoted\\""^^', + graph: G, + }, + ]); + expect(Object.isFrozen(parsed)).toBe(true); + expect(parsed.every(Object.isFrozen)).toBe(true); + expect(estimateSystemRecordInspectionParseBytesV1(body, 2)) + .toBeGreaterThan(retainedSystemRecordInspectionQuadsBytesV1(parsed)); + }); + + it('preflights adversarial JSON structure before allocating its parsed graph', () => { + const body = JSON.stringify(Array.from({ length: 100_000 }, () => 0)); + expect(estimateSystemRecordInspectionParseBytesV1(body, 10_000)) + .toBeGreaterThan(12 * 1024 * 1024); + }); + + it('rejects attacker-controlled nesting with fixed preflight workspace', () => { + const depth = SYSTEM_RECORD_MAX_SHALLOW_JSON_DEPTH + 1; + const body = `${'['.repeat(depth)}0${']'.repeat(depth)}`; + expect(Buffer.byteLength(body, 'utf8')).toBeLessThan(4 * 1024 * 1024); + expect(() => estimateSystemRecordInspectionParseBytesV1(body, 10_000)) + .toThrow(/depth bound/); + }); + + it('orders projection rows by canonical N-Triples bytes while reserved rows retain tuple order', () => { + const root = 'urn:test:root'; + const child = 'urn:test:root/.well-known/genid/cap1'; + const rows = [ + row(root, P, { type: 'uri', value: 'urn:test:object:root' }), + row(child, P, { type: 'uri', value: 'urn:test:object:child' }), + ]; + const projection = parseSystemRecordInspectionResponseV1({ + body: response(rows), scope: 'shadow', allowedSubjects: [root, child], maxRows: 2, + }); + expect(projection.map((quad) => quad.subject)).toEqual([child, root]); + + const reserved = parseSystemRecordInspectionResponseV1({ + body: response(rows), scope: 'reserved', allowedSubjects: [root, child], maxRows: 2, + }); + expect(reserved.map((quad) => quad.subject)).toEqual([root, child]); + }); + + it('rejects cap+1, duplicates, blank nodes, unknown fields and unrequested subjects', () => { + expect(() => parse(response([ + row(S1, P, { type: 'uri', value: 'urn:o:1' }), + row(S2, P, { type: 'uri', value: 'urn:o:2' }), + ]), { maxRows: 1 })).toThrow(/row bound/); + const duplicate = row(S1, P, { type: 'uri', value: 'urn:o:1' }); + expect(() => parse(response([duplicate, duplicate]), { maxRows: 2 })).toThrow(/duplicate/); + expect(() => parse(response([ + row(S1, P, { type: 'bnode', value: 'x' }), + ]))).toThrow(/blank nodes/); + expect(() => parse(JSON.stringify({ + head: { vars: ['s', 'p', 'o'] }, + results: { bindings: [{ ...row(S1, P, { type: 'uri', value: 'urn:o' }), extra: {} }] }, + }))).toThrow(/unknown/); + expect(() => parse(response([ + row('urn:not:requested', P, { type: 'uri', value: 'urn:o' }), + ]))).toThrow(/unrequested/); + }); + + it('rejects malformed/truncated JSON and decoded-term byte overflow', () => { + expect(() => parse('{')).toThrow(/not JSON/); + expect(() => parse(response([ + row(S1, P, { type: 'literal', value: 'abcd' }), + ]), { maxDecodedTermBytes: 3 })).toThrow(/decoded-term/); + expect(() => parse(JSON.stringify({ + head: { vars: ['s', 'p'] }, results: { bindings: [] }, + }))).toThrow(/variables/); + expect(() => parse(response([ + row(S1, P, { type: 'literal', value: 'x', datatype: 'urn:type', 'xml:lang': 'en' }), + ]))).toThrow(/datatype and language/); + expect(() => parse(response([ + row(S1, P, { type: 'literal', value: '\ud800' }), + ]))).toThrow(/non-scalar/); + expect(() => parse(' '.repeat(4 * 1024 * 1024 + 1))).toThrow(/encoded byte/); + expect(() => parse(response([]), { maxRows: 10_001 })).toThrow(/row bound/); + expect(() => parseSystemRecordInspectionResponseV1(new Proxy({ + body: response([]), scope: 'shadow', allowedSubjects: [S1], maxRows: 1, + }, {}))).toThrow(/plain data/); + }); +}); + +function parse( + body: string, + overrides: Partial[0]> = {}, +) { + return parseSystemRecordInspectionResponseV1({ + body, + scope: 'shadow', + allowedSubjects: [S1, S2], + maxRows: 10, + ...overrides, + }); +} + +function response(bindings: unknown[]): string { + return JSON.stringify({ head: { vars: ['s', 'p', 'o'] }, results: { bindings } }); +} + +function row(subject: string, predicate: string, object: Record) { + return { + s: { type: 'uri', value: subject }, + p: { type: 'uri', value: predicate }, + o: object, + }; +} diff --git a/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts b/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts new file mode 100644 index 0000000000..f0a74410f7 --- /dev/null +++ b/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts @@ -0,0 +1,265 @@ +import { createServer, type Server } from 'node:http'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { SparqlHttpStore } from '../src/adapters/sparql-http.js'; +import { + attachManagedOxigraphLeaseV1, + createManagedOxigraphOwnershipControllerV1, + type ManagedOxigraphOwnershipControllerV1, + type ManagedOxigraphSupervisorHandoffV1, +} from '../src/managed-oxigraph-ownership-v1-internal.js'; +import { __resetSystemRecordControllerRegistrationForTests } from '../src/system-record-materializer-v1.js'; +import { externalStorePriorityScheduler } from '../src/store-priority-scheduler.js'; + +let QUERY_ENDPOINT: string; +let UPDATE_ENDPOINT: string; +let server: Server; +let epoch: string | null; +const AGENTS_GRAPH = 'did:dkg:context-graph:agents'; +const UNRELATED_GRAPH = + 'did:dkg:context-graph:0x0000000000000000000000000000000000000001/example'; + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +async function drainTurns(count = 20): Promise { + for (let index = 0; index < count; index += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +const quad = (graph: string) => ({ + subject: 'urn:test:s', + predicate: 'urn:test:p', + object: '"value"', + graph, +}); + +describe('managed Oxigraph mutation admission V1', () => { + let ownership: ManagedOxigraphOwnershipControllerV1; + let store: SparqlHttpStore; + let fetchCalls: number; + const originalFetch = globalThis.fetch; + + const handoff: ManagedOxigraphSupervisorHandoffV1 = { + stopAndProveOwnedChildDead: async () => undefined, + startAndProveCleanGeneration: async () => undefined, + }; + + beforeAll(async () => { + server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + request.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + if (request.url === '/query') { + response.writeHead(200, { 'Content-Type': 'application/sparql-results+json' }); + response.end(JSON.stringify({ + head: { vars: ['epoch'] }, + results: { + bindings: epoch === null ? [] : [{ epoch: { type: 'literal', value: epoch } }], + }, + })); + return; + } + epoch = /INSERT[\s\S]*?materialization-epoch> "([0-9]+)"/u.exec(body)?.[1] ?? null; + response.writeHead(epoch === null ? 400 : 204); + response.end(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('test server has no port'); + QUERY_ENDPOINT = `http://127.0.0.1:${address.port}/query`; + UPDATE_ENDPOINT = `http://127.0.0.1:${address.port}/update`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + __resetSystemRecordControllerRegistrationForTests(); + epoch = null; + ownership = createManagedOxigraphOwnershipControllerV1(QUERY_ENDPOINT, UPDATE_ENDPOINT); + ownership.bindReadyGeneration(); + const options = attachManagedOxigraphLeaseV1( + { queryEndpoint: QUERY_ENDPOINT, updateEndpoint: UPDATE_ENDPOINT }, + ownership.lease, + handoff, + ); + store = new SparqlHttpStore(options); + fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(null, { status: 204 }); + }) as typeof fetch; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + await store.close().catch(() => undefined); + __resetSystemRecordControllerRegistrationForTests(); + }); + + async function activate(): Promise { + const controller = store.getSystemRecordLaneControllerV1?.(); + expect(controller).toBeDefined(); + await controller!.open({ networkId: 'testnet', kinds: ['agents'], mode: 'shadow' }); + } + + function holdAgentsExclusive(): { + readonly entered: Promise; + readonly release: () => void; + readonly work: Promise; + } { + const entered = deferred(); + const gate = deferred(); + const generation = ownership.snapshot().childGeneration; + const work = externalStorePriorityScheduler.run( + 'normal', + 'test.managed-mutation.agents-exclusive', + async () => { + entered.resolve(undefined); + await gate.promise; + }, + undefined, + { storeId: store, generation, domain: 'agents', mode: 'exclusive' }, + ); + return { entered: entered.promise, release: () => gate.resolve(undefined), work }; + } + + it('holds system mutations behind an agents exclusive', async () => { + await activate(); + const exclusive = holdAgentsExclusive(); + await exclusive.entered; + + const systemWrite = store.insert([quad(AGENTS_GRAPH)]); + await drainTurns(); + + expect(fetchCalls).toBe(0); + + exclusive.release(); + await exclusive.work; + await expect(systemWrite).resolves.toBeUndefined(); + expect(fetchCalls).toBe(1); + }); + + it('refuses opaque updates before dispatch while admission is active', async () => { + await activate(); + + await expect(store.update( + 'INSERT DATA { "x" }', + { touchedGraphs: [UNRELATED_GRAPH] }, + )).rejects.toMatchObject({ + code: 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE', + }); + expect(fetchCalls).toBe(0); + }); + + it('keeps an explicit unrelated context-graph mutation concurrent with agents apply', async () => { + await activate(); + const exclusive = holdAgentsExclusive(); + await exclusive.entered; + + await expect(store.insert([quad(UNRELATED_GRAPH)])).resolves.toBeUndefined(); + expect(fetchCalls).toBe(1); + + exclusive.release(); + await exclusive.work; + }); + + it('refuses a queued mutation when its child generation changes before dispatch', async () => { + await activate(); + const exclusive = holdAgentsExclusive(); + await exclusive.entered; + + const write = store.insert([quad(AGENTS_GRAPH)]); + await drainTurns(); + expect(fetchCalls).toBe(0); + + ownership.invalidate('child-revive'); + expect(ownership.bindReadyGeneration()).toBe('2'); + exclusive.release(); + await exclusive.work; + + await expect(write).rejects.toMatchObject({ + code: 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE', + }); + expect(fetchCalls).toBe(0); + }); + + it.each([ + ['temporarily unavailable', 'stop'], + ['terminal', 'port-release-unproven'], + ] as const)('fails closed before I/O when ownership is %s', async (_label, reason) => { + await activate(); + ownership.invalidate(reason); + + await expect(store.insert([quad(UNRELATED_GRAPH)])).rejects.toMatchObject({ + code: 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE', + }); + expect(fetchCalls).toBe(0); + }); + + it('keeps default-off mutations on the zero-metadata scheduler fast path', async () => { + const before = externalStorePriorityScheduler.snapshot; + await expect(store.insert([quad(UNRELATED_GRAPH)])).resolves.toBeUndefined(); + const after = externalStorePriorityScheduler.snapshot; + + expect(after.admissionEvaluations).toBe(before.admissionEvaluations); + expect(after.admissionTrackedStores).toBe(before.admissionTrackedStores); + expect(after.admissionTaggedQueued).toBe(before.admissionTaggedQueued); + expect(after.admissionTaggedInflight).toBe(before.admissionTaggedInflight); + expect(after.admissionHeldRuns).toBe(before.admissionHeldRuns); + expect(fetchCalls).toBe(1); + }); + + it('keeps default-off opaque updates on the legacy dispatch path', async () => { + const before = externalStorePriorityScheduler.snapshot; + await expect(store.update( + 'INSERT DATA { "x" }', + { touchedGraphs: [UNRELATED_GRAPH] }, + )).resolves.toBeUndefined(); + const after = externalStorePriorityScheduler.snapshot; + + expect(after.admissionEvaluations).toBe(before.admissionEvaluations); + expect(after.admissionTrackedStores).toBe(before.admissionTrackedStores); + expect(after.admissionTaggedQueued).toBe(before.admissionTaggedQueued); + expect(after.admissionTaggedInflight).toBe(before.admissionTaggedInflight); + expect(after.admissionHeldRuns).toBe(before.admissionHeldRuns); + expect(fetchCalls).toBe(1); + }); + + it('restores the zero-metadata scheduler fast path after a successful disable', async () => { + const controller = store.getSystemRecordLaneControllerV1?.(); + expect(controller).toBeDefined(); + const session = await controller!.open({ + networkId: 'testnet', + kinds: ['agents'], + mode: 'shadow', + }); + await session.close('disable'); + + const before = externalStorePriorityScheduler.snapshot; + await expect(store.insert([quad(UNRELATED_GRAPH)])).resolves.toBeUndefined(); + const after = externalStorePriorityScheduler.snapshot; + + expect(after.admissionEvaluations).toBe(before.admissionEvaluations); + expect(after.admissionTrackedStores).toBe(before.admissionTrackedStores); + expect(after.admissionTaggedQueued).toBe(before.admissionTaggedQueued); + expect(after.admissionTaggedInflight).toBe(before.admissionTaggedInflight); + expect(after.admissionHeldRuns).toBe(before.admissionHeldRuns); + expect(fetchCalls).toBe(1); + }); +}); diff --git a/packages/storage/test/system-record-materialization-epoch-adapter-v1.test.ts b/packages/storage/test/system-record-materialization-epoch-adapter-v1.test.ts new file mode 100644 index 0000000000..9190f13a48 --- /dev/null +++ b/packages/storage/test/system-record-materialization-epoch-adapter-v1.test.ts @@ -0,0 +1,111 @@ +import { createServer, type Server } from 'node:http'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { SparqlHttpStore } from '../src/adapters/sparql-http.js'; +import { + attachManagedOxigraphLeaseV1, + createManagedOxigraphOwnershipControllerV1, +} from '../src/managed-oxigraph-ownership-v1-internal.js'; +import { __resetSystemRecordControllerRegistrationForTests } from '../src/system-record-materializer-v1.js'; + +let server: Server; +let queryEndpoint: string; +let updateEndpoint: string; +let epoch: string | null; +let requests: Array<{ path: string; body: string }>; + +beforeAll(async () => { + server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + requests.push({ path: req.url ?? '', body }); + if (req.url === '/query') { + res.writeHead(200, { 'Content-Type': 'application/sparql-results+json' }); + res.end(JSON.stringify({ + head: { vars: ['epoch'] }, + results: { + bindings: epoch === null ? [] : [{ epoch: { type: 'literal', value: epoch } }], + }, + })); + return; + } + const inserted = /INSERT[\s\S]*?materialization-epoch> "([0-9]+)"/u.exec(body)?.[1]; + if (inserted === undefined) { + res.writeHead(400); + res.end('missing epoch'); + return; + } + epoch = inserted; + res.writeHead(204); + res.end(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('test server has no port'); + queryEndpoint = `http://127.0.0.1:${address.port}/query`; + updateEndpoint = `http://127.0.0.1:${address.port}/update`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +beforeEach(() => { + __resetSystemRecordControllerRegistrationForTests(); + epoch = null; + requests = []; +}); + +afterEach(() => { + __resetSystemRecordControllerRegistrationForTests(); +}); + +describe('sparql-http managed epoch handoff', () => { + it('rotates through the generation-owned client inside the control barrier', async () => { + const ownership = createManagedOxigraphOwnershipControllerV1(queryEndpoint, updateEndpoint); + ownership.bindReadyGeneration(); + const options = attachManagedOxigraphLeaseV1( + { queryEndpoint, updateEndpoint }, + ownership.lease, + { + stopAndProveOwnedChildDead: async () => undefined, + startAndProveCleanGeneration: async () => undefined, + }, + ); + const store = new SparqlHttpStore(options); + const controller = store.getSystemRecordLaneControllerV1(); + expect(controller).toBeDefined(); + + const first = await controller!.open({ networkId: 'testnet', kinds: ['agents'], mode: 'shadow' }); + expect(epoch).toBe('1'); + await first.close('disable'); + expect(epoch).toBe('2'); + const second = await controller!.open({ networkId: 'testnet', kinds: ['agents'], mode: 'shadow' }); + expect(epoch).toBe('3'); + expect(requests.map((request) => request.path)).toEqual([ + '/query', '/update', '/query', + '/query', '/update', '/query', + '/query', '/update', '/query', + ]); + expect(requests.filter((request) => request.path === '/query')) + .toSatisfy((queries: Array<{ body: string }>) => + queries.every((query) => query.body.endsWith('LIMIT 2')), + ); + + const beforeForgedApply = requests.length; + // Production composition now reaches the atomic executor's private + // registry consumer. A caller-authored object is rejected there before a + // reserved-state query or update; the former validation-mismatch stub is + // no longer the production bound path. + await expect(second.applyVerified(Object.freeze({}))).resolves.toEqual({ + outcome: 'capability-lost', + }); + expect(requests).toHaveLength(beforeForgedApply); + + await second.close('shutdown'); + await store.close(); + }); +}); diff --git a/packages/storage/test/system-record-materialization-epoch-v1.test.ts b/packages/storage/test/system-record-materialization-epoch-v1.test.ts new file mode 100644 index 0000000000..8bf7a2735e --- /dev/null +++ b/packages/storage/test/system-record-materialization-epoch-v1.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { OwnedManagedHttpClient } from '../src/adapters/managed-http-client.js'; +import { + createManagedOxigraphOwnershipControllerV1, + type ManagedOxigraphOwnershipControllerV1, +} from '../src/managed-oxigraph-ownership-v1-internal.js'; +import { rotateSystemRecordMaterializationEpochV1 } from '../src/system-record-materialization-epoch-v1-internal.js'; + +const QUERY_ENDPOINT = 'http://127.0.0.1:7878/query'; +const UPDATE_ENDPOINT = 'http://127.0.0.1:7878/update'; + +const select = (values: readonly unknown[]): string => JSON.stringify({ + head: { vars: ['epoch'] }, + results: { + bindings: values.map((value) => ({ + epoch: typeof value === 'object' ? value : { type: 'literal', value }, + })), + }, +}); + +class FakeOwnedClient { + readonly childGeneration = '1'; + readonly calls: Array<{ url: string; contentType: string; body: string }> = []; + values: unknown[] = []; + updateMode: 'commit' | 'no-commit' | 'throw-after-commit' | 'cas-miss' = 'commit'; + afterUpdate?: () => void; + malformedResponse: string | null = null; + + async post(url: string, contentType: string, body: string): Promise<{ status: number; body: string }> { + this.calls.push({ url, contentType, body }); + if (url === QUERY_ENDPOINT) { + const response = this.malformedResponse ?? select(this.values); + this.malformedResponse = null; + return { status: 200, body: response }; + } + + const inserted = /INSERT[\s\S]*?materialization-epoch> "([0-9]+)"/u.exec(body)?.[1]; + if (inserted === undefined) throw new Error('test update did not contain an inserted epoch'); + if (this.updateMode === 'commit' || this.updateMode === 'throw-after-commit') { + this.values = [inserted]; + } else if (this.updateMode === 'cas-miss') { + this.values = ['99']; + } + this.afterUpdate?.(); + if (this.updateMode === 'throw-after-commit') throw new Error('response lost'); + return { status: 204, body: '' }; + } +} + +describe('system-record materialization epoch V1', () => { + let ownership: ManagedOxigraphOwnershipControllerV1; + let client: FakeOwnedClient; + + const rotate = (networkId = 'testnet') => rotateSystemRecordMaterializationEpochV1({ + networkId, + lease: ownership.lease, + client: client as unknown as OwnedManagedHttpClient, + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + }); + + beforeEach(() => { + ownership = createManagedOxigraphOwnershipControllerV1(QUERY_ENDPOINT, UPDATE_ENDPOINT); + ownership.bindReadyGeneration(); + client = new FakeOwnedClient(); + }); + + it('creates epoch 1 with an exact LIMIT 2 read and one conditional Modify', async () => { + await expect(rotate()).resolves.toEqual({ epoch: '1', childGeneration: '1' }); + expect(client.calls).toHaveLength(3); + expect(client.calls[0]?.body).toMatch(/SELECT \?epoch[\s\S]*LIMIT 2$/); + expect(client.calls[1]?.body).toMatch(/^INSERT \{/); + expect(client.calls[1]?.body).toContain('FILTER NOT EXISTS'); + expect(client.calls[1]?.body).not.toContain(';'); + expect(client.calls[2]?.body).toBe(client.calls[0]?.body); + }); + + it('increments an existing singleton with one exact conditional DELETE/INSERT/WHERE', async () => { + client.values = ['41']; + await expect(rotate()).resolves.toEqual({ epoch: '42', childGeneration: '1' }); + const update = client.calls[1]?.body ?? ''; + expect(update.match(/DELETE/g)).toHaveLength(1); + expect(update.match(/INSERT/g)).toHaveLength(1); + expect(update).toContain('"41"'); + expect(update).toContain('"42"'); + expect(update).not.toContain(';'); + }); + + it('resolves a lost update response from the bounded post-read', async () => { + client.values = ['8']; + client.updateMode = 'throw-after-commit'; + await expect(rotate()).resolves.toEqual({ epoch: '9', childGeneration: '1' }); + expect(client.calls).toHaveLength(3); + }); + + it('fails closed when the conditional update leaves the epoch unchanged', async () => { + client.values = ['8']; + client.updateMode = 'no-commit'; + await expect(rotate()).rejects.toThrow(/did not commit the expected value 9/); + }); + + it('fails closed on a competing value after a CAS miss', async () => { + client.values = ['8']; + client.updateMode = 'cas-miss'; + await expect(rotate()).rejects.toThrow(/did not commit the expected value 9/); + }); + + it.each([ + [['1', '1'], /multiple persisted values/], + [['01'], /canonical decimal u64/], + [[{ type: 'uri', value: '1' }], /plain literal/], + [['18446744073709551616'], /exceeds u64/], + ] as const)('rejects malformed or extra persisted epoch state', async (values, error) => { + client.values = [...values]; + await expect(rotate()).rejects.toThrow(error); + expect(client.calls).toHaveLength(1); + }); + + it('refuses to rotate past max-u64 before issuing an update', async () => { + client.values = ['18446744073709551615']; + await expect(rotate()).rejects.toThrow(/cannot advance beyond u64/); + expect(client.calls).toHaveLength(1); + }); + + it('rejects a malformed SELECT envelope', async () => { + client.malformedResponse = JSON.stringify({ head: { vars: ['epoch'] }, results: { bindings: [] }, extra: true }); + await expect(rotate()).rejects.toThrow(/unknown or missing fields/); + }); + + it('rechecks generation ownership before recovery and never reads a replacement child', async () => { + client.afterUpdate = () => { + ownership.invalidate('child-exit'); + ownership.bindReadyGeneration(); + }; + await expect(rotate()).rejects.toThrow(/ownership changed/); + expect(client.calls).toHaveLength(2); + }); + + it('requires exact supervisor-proven endpoints before sending any bytes', async () => { + await expect(rotateSystemRecordMaterializationEpochV1({ + networkId: 'testnet', + lease: ownership.lease, + client: client as unknown as OwnedManagedHttpClient, + queryEndpoint: 'http://127.0.0.1:7879/query', + updateEndpoint: UPDATE_ENDPOINT, + })).rejects.toThrow(/ownership changed/); + expect(client.calls).toHaveLength(0); + }); + + it('derives distinct exact epoch subjects for distinct networks', async () => { + await rotate('testnet'); + const testnetQuery = client.calls[0]?.body; + client.calls.length = 0; + client.values = []; + await rotate('mainnet-gnosis'); + expect(client.calls[0]?.body).not.toBe(testnetQuery); + }); +}); 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 f6c312bc2c..e52b9401a1 100644 --- a/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts +++ b/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createManagedOxigraphOwnershipControllerV1, + readManagedOxigraphOwnershipSnapshotV1, type ManagedOxigraphOwnershipControllerV1, } from '../src/managed-oxigraph-ownership-v1-internal.js'; import { @@ -12,8 +13,16 @@ import { type SystemRecordApplyOutcomeV1, type SystemRecordChildHandoffV1, type SystemRecordLaneActivationV1, + type SystemRecordLaneExecutionBindingV1, type SystemRecordLaneSessionV1, } from '../src/system-record-materializer-v1.js'; +import type { + SystemRecordAtomicApplySettlementV1, + SystemRecordAtomicRecoveryRegistrarV1, + SystemRecordAtomicRecoveryRegistrationV1, + SystemRecordAtomicRecoveryResolutionV1, + SystemRecordAtomicRecoveryRuntimeV1, +} from '../src/system-record-atomic-apply-executor-v1-internal.js'; const ACTIVATION: SystemRecordLaneActivationV1 = { networkId: 'testnet', @@ -21,6 +30,9 @@ const ACTIVATION: SystemRecordLaneActivationV1 = { mode: 'shadow', }; +const OWNERSHIP_QUERY_ENDPOINT = 'http://127.0.0.1:7878/query'; +const OWNERSHIP_UPDATE_ENDPOINT = 'http://127.0.0.1:7878/update'; + /** Records the exact handoff order so the ordering invariant is observable. */ class RecordingHandoff implements SystemRecordChildHandoffV1 { readonly calls: string[] = []; @@ -37,7 +49,15 @@ class RecordingHandoff implements SystemRecordChildHandoffV1 { stopAndProveOwnedChildDead = () => this.step('stopAndProveOwnedChildDead'); awaitRetiredWork = () => this.step('awaitRetiredWork'); startAndProveCleanGeneration = () => this.step('startAndProveCleanGeneration'); - rotateMaterializationEpoch = () => this.step('rotateMaterializationEpoch'); + failManagedMutationsClosed = (reason: string) => { + this.calls.push(`failManagedMutationsClosed:${reason}`); + }; + private epoch = 0; + rotateMaterializationEpoch = async (_networkId?: string) => { + await this.step('rotateMaterializationEpoch'); + this.epoch += 1; + return Object.freeze({ epoch: String(this.epoch), childGeneration: '1' }); + }; } /** @@ -90,9 +110,14 @@ class StubExecutor { appliedStateDigest: `0x${'a'.repeat(64)}`, }; - calls: Array<{ childGeneration: string }> = []; + calls: SystemRecordLaneExecutionBindingV1[] = []; + discarded: unknown[] = []; onDispatch?: () => void; + discardVerified = (proof: unknown): void => { + this.discarded.push(proof); + }; + /** Park the dispatch so a whole lifecycle transition can run underneath it. */ private gate: Promise | null = null; private releaseGate: (() => void) | null = null; @@ -109,7 +134,28 @@ class StubExecutor { } async applyVerified(_proof: unknown, childGeneration: string): Promise { - this.calls.push({ childGeneration }); + return this.dispatch({ + activationGeneration: 'legacy', + networkId: 'legacy', + kind: 'agents', + mode: 'shadow', + sessionIdentity: Object.freeze(Object.create(null) as object), + childGeneration, + materializationEpoch: '0', + }); + } + + async applyVerifiedBound( + _proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + ): Promise { + return this.dispatch(binding); + } + + private async dispatch( + binding: SystemRecordLaneExecutionBindingV1, + ): Promise { + this.calls.push(binding); this.onDispatch?.(); if (this.gate) { this.reachedGate?.(); @@ -119,6 +165,269 @@ class StubExecutor { } } +class AtomicRecoveryExecutor extends StubExecutor { + settlement: 'uncertain' | 'no-mutation' = 'uncertain'; + noMutationOutcome: SystemRecordApplyOutcomeV1 = { outcome: 'stale' }; + recoveryResolution: SystemRecordAtomicRecoveryResolutionV1 = { + resolution: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'2'.repeat(64)}`, + }; + registration: SystemRecordAtomicRecoveryRegistrationV1 | null = null; + recoveredRuntime: SystemRecordAtomicRecoveryRuntimeV1 | null = null; + + private beforeRegistrationGate: Promise | null = null; + private releaseBeforeRegistration: (() => void) | null = null; + private reachedBeforeRegistration: (() => void) | null = null; + registrationReached: Promise = Promise.resolve(); + private reconcileGate: Promise | null = null; + private releaseReconcileGate: (() => void) | null = null; + private reachedReconcile: (() => void) | null = null; + reconcileReached: Promise = Promise.resolve(); + private waitForReconcileAbort = false; + + parkBeforeRegistration(): void { + this.beforeRegistrationGate = new Promise((resolve) => { + this.releaseBeforeRegistration = resolve; + }); + this.registrationReached = new Promise((resolve) => { + this.reachedBeforeRegistration = resolve; + }); + } + + releaseRegistration(): void { + this.releaseBeforeRegistration?.(); + } + + parkReconcile(): void { + this.reconcileGate = new Promise((resolve) => { + this.releaseReconcileGate = resolve; + }); + this.reconcileReached = new Promise((resolve) => { + this.reachedReconcile = resolve; + }); + } + + releaseReconcile(): void { + this.releaseReconcileGate?.(); + } + + parkReconcileUntilAbort(): void { + this.waitForReconcileAbort = true; + this.reconcileReached = new Promise((resolve) => { + this.reachedReconcile = resolve; + }); + } + + async applyVerifiedSettlementBound( + _proof: unknown, + binding: SystemRecordLaneExecutionBindingV1, + registerRecovery: SystemRecordAtomicRecoveryRegistrarV1, + ): Promise { + this.calls.push(binding); + this.onDispatch?.(); + if (this.settlement === 'no-mutation') { + return Object.freeze({ settlement: 'no-mutation', outcome: this.noMutationOutcome }) as never; + } + if (this.beforeRegistrationGate) { + this.reachedBeforeRegistration?.(); + await this.beforeRegistrationGate; + } + const ownership = Object.freeze(Object.create(null) as object); + const registration = registerRecovery(Object.freeze({ + ownership, + binding, + reconcile: async (runtime: SystemRecordAtomicRecoveryRuntimeV1) => { + this.recoveredRuntime = runtime; + if (this.waitForReconcileAbort) { + this.reachedReconcile?.(); + if (!runtime.signal.aborted) { + await new Promise((resolve) => { + runtime.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + } + return { resolution: 'unavailable' }; + } + if (this.reconcileGate) { + this.reachedReconcile?.(); + await this.reconcileGate; + } + return this.recoveryResolution; + }, + })); + this.registration = registration; + return Object.freeze({ + settlement: 'recovery-owned', + outcome: Object.freeze({ + outcome: 'indeterminate', + recoveryGeneration: registration.recoveryGeneration, + }), + recovery: registration, + }); + } +} + +class RecoveryHandoff extends RecordingHandoff { + private epoch = 0; + readonly recoveryDeadlines: Array<{ phase: string; value: number | undefined }> = []; + + constructor(private readonly ownership: ManagedOxigraphOwnershipControllerV1) { + super(); + } + + override stopAndProveOwnedChildDead = async (absoluteDeadlineMs?: number): Promise => { + this.recoveryDeadlines.push({ phase: 'stop', value: absoluteDeadlineMs }); + await this.step('stopAndProveOwnedChildDead'); + }; + + override destroyClient = async (absoluteDeadlineMs?: number): Promise => { + this.recoveryDeadlines.push({ phase: 'destroy', value: absoluteDeadlineMs }); + await this.step('destroyClient'); + }; + + override awaitRetiredWork = async (absoluteDeadlineMs?: number): Promise => { + this.recoveryDeadlines.push({ phase: 'drain', value: absoluteDeadlineMs }); + await this.step('awaitRetiredWork'); + }; + + override startAndProveCleanGeneration = async (absoluteDeadlineMs?: number): Promise => { + this.recoveryDeadlines.push({ phase: 'start', value: absoluteDeadlineMs }); + await this.step('startAndProveCleanGeneration'); + // Initial enable already has generation 1. Every later start is a + // controlled recovery/re-enable and must bind a genuinely new listener. + if (this.epoch > 0) { + this.ownership.invalidate('child-exit'); + this.ownership.bindReadyGeneration(); + } + }; + + override rotateMaterializationEpoch = async (_networkId?: string) => { + await this.step('rotateMaterializationEpoch'); + this.epoch += 1; + const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownership.lease); + if (!snapshot?.ready) throw new Error('test ownership is not ready'); + return Object.freeze({ epoch: String(this.epoch), childGeneration: snapshot.childGeneration }); + }; + + override createRecoveryRuntime = ( + binding: SystemRecordLaneExecutionBindingV1, + absoluteDeadlineMs: number, + signal: AbortSignal, + ): SystemRecordAtomicRecoveryRuntimeV1 => { + this.recoveryDeadlines.push({ phase: 'exact-read', value: absoluteDeadlineMs }); + this.calls.push('createRecoveryRuntime'); + this.barrier?.note('createRecoveryRuntime'); + if (this.failAt === 'createRecoveryRuntime') { + throw new Error('handoff failed at createRecoveryRuntime'); + } + return Object.freeze({ + client: { + childGeneration: binding.childGeneration, + isDestroyed: false, + post: async () => { throw new Error('test recovery callback owns the read'); }, + }, + queryEndpoint: OWNERSHIP_QUERY_ENDPOINT, + absoluteDeadlineMs, + signal, + assertAttributable: () => { + const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownership.lease); + return Boolean(snapshot?.ready && snapshot.childGeneration === binding.childGeneration); + }, + }); + }; +} + +class GateBarrier extends RecordingBarrier { + private readonly gates = new Map>(); + private readonly releases = new Map void>(); + private readonly arrivals = new Map>(); + private readonly arrived = new Map void>(); + + constructor() { + super(); + const recordAndRun = this.run; + this.run = async (purpose: string, transition: () => Promise): Promise => { + const gate = this.gates.get(purpose); + if (gate) { + this.arrived.get(purpose)?.(); + await gate; + } + return recordAndRun(purpose, transition); + }; + } + + gate(purpose: string): void { + let release!: () => void; + this.gates.set(purpose, new Promise((resolve) => { release = resolve; })); + this.releases.set(purpose, release); + let arrive!: () => void; + this.arrivals.set(purpose, new Promise((resolve) => { arrive = resolve; })); + this.arrived.set(purpose, arrive); + } + + reached(purpose: string): Promise { + return this.arrivals.get(purpose) ?? Promise.resolve(); + } + + release(purpose: string): void { + this.releases.get(purpose)?.(); + } + +} + +/** + * Scheduler-faithful transition-timeout model. + * + * Like `StorePriorityScheduler.runControlBarrier`, the public promise can + * reject after the callback started while the callback and exclusive section + * remain alive until their own promise settles. + */ +class TransitionTimeoutBarrier extends RecordingBarrier { + physicalSettled = false; + private readonly rejects = new Map void>(); + + constructor() { + super(); + const recordAndRun = this.run; + this.run = (purpose: string, transition: () => Promise): Promise => { + if ( + purpose !== 'system-record.shutdown' && + purpose !== 'system-record.disable' && + purpose !== 'system-record.recovery' + ) return recordAndRun(purpose, transition); + + const physical = recordAndRun(purpose, transition); + void physical.finally(() => { this.physicalSettled = true; }).catch(() => undefined); + return new Promise((resolve, reject) => { + this.rejects.set(purpose, reject); + physical.then(resolve, reject); + }); + }; + } + + timeoutShutdown(): void { + this.timeout('system-record.shutdown'); + } + + timeout(purpose: string): void { + this.rejects.get(purpose)?.(new Error('STORE_CONTROL_BARRIER_TRANSITION_TIMEOUT')); + } +} + +/** Models the scheduler rejecting before the shutdown callback is admitted. */ +class WaitPhaseTimeoutBarrier extends RecordingBarrier { + constructor() { + super(); + const recordAndRun = this.run; + this.run = (purpose: string, transition: () => Promise): Promise => { + if (purpose === 'system-record.shutdown') { + return Promise.reject(new Error('STORE_CONTROL_BARRIER_WAIT_TIMEOUT')); + } + return recordAndRun(purpose, transition); + }; + } +} + describe('system-record lane session lifecycle V1', () => { let ownership: ManagedOxigraphOwnershipControllerV1; let handoff: RecordingHandoff; @@ -137,7 +446,10 @@ describe('system-record lane session lifecycle V1', () => { beforeEach(() => { __resetSystemRecordControllerRegistrationForTests(); - ownership = createManagedOxigraphOwnershipControllerV1(); + ownership = createManagedOxigraphOwnershipControllerV1( + OWNERSHIP_QUERY_ENDPOINT, + OWNERSHIP_UPDATE_ENDPOINT, + ); ownership.bindReadyGeneration(); barrier = new RecordingBarrier(); handoff = new RecordingHandoff(); @@ -187,6 +499,11 @@ describe('system-record lane session lifecycle V1', () => { expect(b).toBe(c); expect(handoff.calls.filter((s) => s === 'startAndProveCleanGeneration')).toHaveLength(1); expect(a.activationGeneration).toBe('1'); + expect(b.activationGeneration).toBe('1'); + expect(c.activationGeneration).toBe('1'); + expect((await a.applyVerified({})).outcome).toBe('applied'); + expect((await b.applyVerified({})).outcome).toBe('applied'); + expect(executor.calls.map((call) => call.activationGeneration)).toEqual(['1', '1']); }); it('is idempotent for a repeated same-descriptor open', async () => { @@ -206,6 +523,48 @@ describe('system-record lane session lifecycle V1', () => { ).rejects.toThrow(SystemRecordLaneActivationConflictError); }); + it('snapshots a closed activation without invoking caller accessors or iterators', async () => { + let accessorCalls = 0; + const accessorBacked = Object.create(null) as Record; + Object.defineProperties(accessorBacked, { + networkId: { + enumerable: true, + get: () => { accessorCalls += 1; return 'testnet'; }, + }, + kinds: { enumerable: true, value: ['agents'] }, + mode: { enumerable: true, value: 'shadow' }, + }); + + await expect(build().open(accessorBacked as never)).rejects.toThrow(/data properties/); + expect(accessorCalls).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( + /unknown or missing fields/, + ); + + const kinds = ['agents'] as string[] & { extra?: boolean }; + kinds.extra = true; + await expect(controller.open({ ...ACTIVATION, kinds } as never)).rejects.toThrow( + /closed \[agents\] tuple/, + ); + expect(handoff.calls).toEqual([]); + }); + + it('rejects a non-canonical network and invalid mode before handoff', async () => { + const controller = build(); + await expect(controller.open({ ...ACTIVATION, networkId: 'not a network' })).rejects.toThrow( + /networkId is not canonical/, + ); + await expect(controller.open({ ...ACTIVATION, mode: 'observe' } as never)).rejects.toThrow( + /mode is invalid/, + ); + expect(handoff.calls).toEqual([]); + }); + for (const failAt of [ 'destroyClient', 'stopAndProveOwnedChildDead', @@ -240,6 +599,56 @@ describe('system-record lane session lifecycle V1', () => { await expect(build().open(ACTIVATION)).rejects.toThrow(/terminal/); expect(handoff.calls).toEqual([]); }); + + it('keeps a B2 void epoch rotation compatible with the legacy executor', async () => { + const legacyHandoff: SystemRecordChildHandoffV1 = { + destroyClient: async () => undefined, + stopAndProveOwnedChildDead: async () => undefined, + awaitRetiredWork: async () => undefined, + startAndProveCleanGeneration: async () => undefined, + rotateMaterializationEpoch: async () => undefined, + }; + const legacyCalls: string[] = []; + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: legacyHandoff, + executor: { + applyVerified: async (_proof, childGeneration) => { + legacyCalls.push(childGeneration); + return { + outcome: 'applied', + stateRevision: '1', + appliedStateDigest: `0x${'e'.repeat(64)}`, + }; + }, + }, + barrier: barrier.run, + }); + + const session = await controller.open(ACTIVATION); + expect(session.state).toBe('enabled'); + expect((await session.applyVerified({})).outcome).toBe('applied'); + expect(legacyCalls).toEqual(['1']); + }); + + it('fails closed when a B3 settlement executor receives no epoch binding', async () => { + const legacyHandoff: SystemRecordChildHandoffV1 = { + destroyClient: async () => undefined, + stopAndProveOwnedChildDead: async () => undefined, + awaitRetiredWork: async () => undefined, + startAndProveCleanGeneration: async () => undefined, + rotateMaterializationEpoch: async () => undefined, + }; + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: legacyHandoff, + executor: new AtomicRecoveryExecutor(), + barrier: barrier.run, + }); + + await expect(controller.open(ACTIVATION)).rejects.toThrow(/materialization epoch binding/); + await expect(controller.open(ACTIVATION)).rejects.toThrow(/terminal/); + }); }); describe('registration invariant', () => { @@ -266,6 +675,7 @@ describe('system-record lane session lifecycle V1', () => { handoff.calls.length = 0; const reopened = await controller.open(ACTIVATION); + expect(reopened).not.toBe(session); expect(reopened.state).toBe('enabled'); expect(reopened.activationGeneration).toBe('2'); expect(handoff.calls).toEqual([ @@ -299,6 +709,26 @@ describe('system-record lane session lifecycle V1', () => { expect(session.state).toBe('shutdown'); }); + for (const failAt of ['destroyClient', 'stopAndProveOwnedChildDead'] as const) { + it(`keeps registration claimed after physical shutdown fails at ${failAt}`, async () => { + const session = await build().open(ACTIVATION); + handoff.failAt = failAt; + + await expect(session.close('shutdown')).rejects.toThrow(/handoff failed/); + const callsAfterFailure = handoff.calls.length; + await expect(session.close('shutdown')).rejects.toThrow(/handoff failed/); + expect(handoff.calls).toHaveLength(callsAfterFailure); + expect(() => + createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor, + barrier: barrier.run, + }), + ).toThrow(SystemRecordControllerRegistrationError); + }); + } + it('is idempotent for a repeated shutdown', async () => { const session = await build().open(ACTIVATION); await session.close('shutdown'); @@ -605,6 +1035,134 @@ describe('system-record lane session lifecycle V1', () => { expect(gated.calls.filter((c) => c === 'stopAndProveOwnedChildDead')).toHaveLength(2); }); + it('retains a timed-out shutdown until the scheduler callback physically settles', async () => { + const timeoutBarrier = new TransitionTimeoutBarrier(); + const gated = new GatedHandoff(); + gated.barrier = timeoutBarrier; + __resetSystemRecordControllerRegistrationForTests(); + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: gated, + executor, + barrier: timeoutBarrier.run, + }); + const session = await controller.open(ACTIVATION); + gated.gate('destroyClient'); + + const first = track(session.close('shutdown')); + await gated.reached('destroyClient'); + timeoutBarrier.timeoutShutdown(); + await first.done; + + expect(first.state.rejected).toBe(true); + expect(String((first.state.value as Error).message)).toMatch(/TRANSITION_TIMEOUT/); + expect(session.state).toBe('shutdown'); + expect(timeoutBarrier.physicalSettled).toBe(false); + + // The first close reports the scheduler timeout, but ownership remains + // with the callback still parked under the scheduler's exclusive seal. + const second = track(session.close('shutdown')); + await drain(); + expect(second.state.settled).toBe(false); + expect(() => + createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor, + barrier: barrier.run, + }), + ).toThrow(SystemRecordControllerRegistrationError); + + gated.release('destroyClient'); + await second.done; + expect(second.state.rejected).toBe(false); + expect(timeoutBarrier.physicalSettled).toBe(true); + expect(gated.calls.filter((c) => c === 'destroyClient')).toHaveLength(2); + expect(() => + createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor, + barrier: barrier.run, + }), + ).not.toThrow(); + }); + + it('retains a wait-phase timed-out shutdown without claiming physical teardown', async () => { + const timeoutBarrier = new WaitPhaseTimeoutBarrier(); + const gated = new GatedHandoff(); + gated.barrier = timeoutBarrier; + __resetSystemRecordControllerRegistrationForTests(); + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: gated, + executor, + barrier: timeoutBarrier.run, + }); + const session = await controller.open(ACTIVATION); + gated.calls.length = 0; + + await expect(session.close('shutdown')).rejects.toThrow(/WAIT_TIMEOUT/); + expect(session.state).toBe('shutdown'); + expect(gated.calls).toEqual([ + 'failManagedMutationsClosed:shutdown transition did not physically settle', + ]); + + // No callback ran, so no physical child/client proof exists. Replaying a + // teardown outside the rejected scheduler request would violate the + // control boundary; retain and report the same failure instead. + await expect(session.close('shutdown')).rejects.toThrow(/WAIT_TIMEOUT/); + expect(gated.calls).toEqual([ + 'failManagedMutationsClosed:shutdown transition did not physically settle', + ]); + expect(() => + createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor, + barrier: barrier.run, + }), + ).toThrow(SystemRecordControllerRegistrationError); + }); + + it('retains a timed-out disable until its scheduler callback settles', async () => { + const timeoutBarrier = new TransitionTimeoutBarrier(); + const gated = new GatedHandoff(); + gated.barrier = timeoutBarrier; + __resetSystemRecordControllerRegistrationForTests(); + const admissionStates: boolean[] = []; + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: gated, + executor, + barrier: timeoutBarrier.run, + setAdmissionActive: (active) => admissionStates.push(active), + }); + const session = await controller.open(ACTIVATION); + expect(admissionStates).toEqual([true]); + gated.gate('awaitRetiredWork'); + + const first = track(session.close('disable')); + await gated.reached('awaitRetiredWork'); + timeoutBarrier.timeout('system-record.disable'); + await first.done; + expect(first.state.rejected).toBe(true); + expect(session.state).toBe('disabling'); + expect(admissionStates).toEqual([true]); + + const second = track(session.close('disable')); + await drain(); + expect(second.state.settled).toBe(false); + + gated.release('awaitRetiredWork'); + await second.done; + expect(second.state.rejected).toBe(false); + expect(session.state).toBe('disabled'); + expect(admissionStates).toEqual([true, false]); + expect(gated.calls.filter((call) => call === 'rotateMaterializationEpoch')) + .toHaveLength(2); + }); + it('runs ONE teardown when a superseded open settles under a stalled shutdown', async () => { const { gated, controller } = buildGated(); const first = await controller.open(ACTIVATION); @@ -933,7 +1491,10 @@ describe('system-record lane session lifecycle V1', () => { it('refuses to report enabled when the handoff bound no ready generation', async () => { // The handoff resolves every step but never binds a proven-ready child. - const silent = createManagedOxigraphOwnershipControllerV1(); + const silent = createManagedOxigraphOwnershipControllerV1( + OWNERSHIP_QUERY_ENDPOINT, + OWNERSHIP_UPDATE_ENDPOINT, + ); __resetSystemRecordControllerRegistrationForTests(); const controller = createSystemRecordLaneControllerV1({ lease: silent.lease, @@ -1003,7 +1564,9 @@ describe('system-record lane session lifecycle V1', () => { const controller = build(); await expect(controller.open(ACTIVATION)).rejects.toThrow(/BARRIER_TIMEOUT/); - expect(handoff.calls).toEqual([]); + expect(handoff.calls).toEqual([ + 'failManagedMutationsClosed:enable transition did not physically settle', + ]); await expect(controller.open(ACTIVATION)).rejects.toThrow(/terminal/); }); @@ -1034,7 +1597,117 @@ describe('system-record lane session lifecycle V1', () => { const session = await build().open(ACTIVATION); const result = await session.applyVerified({}); expect(result.outcome).toBe('applied'); - expect(executor.calls).toEqual([{ childGeneration: '1' }]); + expect(executor.calls).toEqual([{ + activationGeneration: '1', + networkId: 'testnet', + kind: 'agents', + mode: 'shadow', + sessionIdentity: expect.any(Object), + childGeneration: '1', + materializationEpoch: '1', + }]); + expect(Object.getPrototypeOf(executor.calls[0]?.sessionIdentity)).toBeNull(); + expect(Reflect.ownKeys(executor.calls[0]?.sessionIdentity ?? {})).toHaveLength(0); + expect(Object.isFrozen(executor.calls[0]?.sessionIdentity)).toBe(true); + expect(Object.isFrozen(executor.calls[0])).toBe(true); + }); + + it('keeps an explicit child-generation fallback for the current adapter', async () => { + const legacyCalls: string[] = []; + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff, + barrier: barrier.run, + executor: { + applyVerified: async (_proof, childGeneration) => { + legacyCalls.push(childGeneration); + return { + outcome: 'applied', + stateRevision: '1', + appliedStateDigest: `0x${'e'.repeat(64)}`, + }; + }, + }, + }); + + const session = await controller.open(ACTIVATION); + expect((await session.applyVerified({})).outcome).toBe('applied'); + expect(legacyCalls).toEqual(['1']); + }); + + it('refuses a facade from before disable/reopen even for the same descriptor', async () => { + const controller = build(); + const stale = await controller.open(ACTIVATION); + await stale.close('disable'); + const current = await controller.open(ACTIVATION); + + expect(stale.activationGeneration).toBe('1'); + expect(current.activationGeneration).toBe('2'); + const refusedProof = Object.freeze({ proof: 'stale-facade' }); + expect(await stale.applyVerified(refusedProof)).toEqual({ + outcome: 'deferred', + reason: 'generation-changed', + }); + expect(executor.calls).toHaveLength(0); + expect(executor.discarded).toEqual([refusedProof]); + + expect((await current.applyVerified({})).outcome).toBe('applied'); + expect(executor.calls).toHaveLength(1); + expect(executor.calls[0]?.activationGeneration).toBe('2'); + expect(executor.calls[0]?.materializationEpoch).toBe('3'); + }); + + it('does not let a shadow facade inherit a later authoritative activation', async () => { + const controller = build(); + const shadow = await controller.open(ACTIVATION); + await shadow.close('disable'); + const authoritative = await controller.open({ ...ACTIVATION, mode: 'authoritative' }); + + expect(await shadow.applyVerified({})).toEqual({ + outcome: 'deferred', + reason: 'generation-changed', + }); + expect((await authoritative.applyVerified({})).outcome).toBe('applied'); + expect(executor.calls).toEqual([expect.objectContaining({ + activationGeneration: '2', + networkId: 'testnet', + mode: 'authoritative', + })]); + }); + + it('does not let a facade cross into a later network activation', async () => { + const controller = build(); + const testnet = await controller.open(ACTIVATION); + await testnet.close('disable'); + const mainnet = await controller.open({ ...ACTIVATION, networkId: 'mainnet-gnosis' }); + + expect(await testnet.applyVerified({})).toEqual({ + outcome: 'deferred', + reason: 'generation-changed', + }); + expect((await mainnet.applyVerified({})).outcome).toBe('applied'); + expect(executor.calls).toEqual([expect.objectContaining({ + activationGeneration: '2', + networkId: 'mainnet-gnosis', + mode: 'shadow', + })]); + }); + + it('keeps close aggregate while apply remains activation-scoped', async () => { + const controller = build(); + const stale = await controller.open(ACTIVATION); + await stale.close('disable'); + const current = await controller.open(ACTIVATION); + + // A facade is not an independently owned lane. Its close still controls + // the one aggregate session, even though its apply authority is stale. + await stale.close('disable'); + expect(current.state).toBe('disabled'); + expect(await current.applyVerified({})).toEqual({ + outcome: 'deferred', + reason: 'generation-changed', + }); + expect(executor.calls).toHaveLength(0); }); it('refuses before enable without dispatching', async () => { @@ -1048,34 +1721,42 @@ describe('system-record lane session lifecycle V1', () => { const session = (await controller.open(ACTIVATION)) as SystemRecordLaneSessionV1; await session.close('disable'); - const result = await session.applyVerified({}); + const refusedProof = Object.freeze({ proof: 'disabled' }); + const result = await session.applyVerified(refusedProof); expect(result).toEqual({ outcome: 'deferred', reason: 'generation-changed' }); expect(executor.calls).toHaveLength(0); + expect(executor.discarded).toEqual([refusedProof]); }); it('returns capability-lost after shutdown with zero dispatch', async () => { const session = await build().open(ACTIVATION); await session.close('shutdown'); - expect(await session.applyVerified({})).toEqual({ outcome: 'capability-lost' }); + const refusedProof = Object.freeze({ proof: 'shutdown' }); + expect(await session.applyVerified(refusedProof)).toEqual({ outcome: 'capability-lost' }); expect(executor.calls).toHaveLength(0); + expect(executor.discarded).toEqual([refusedProof]); }); it('defers without dispatch while ownership is not ready', async () => { const session = await build().open(ACTIVATION); ownership.invalidate('child-exit'); + const refusedProof = Object.freeze({ proof: 'not-ready' }); - expect(await session.applyVerified({})).toEqual({ + expect(await session.applyVerified(refusedProof)).toEqual({ outcome: 'deferred', reason: 'generation-changed', }); expect(executor.calls).toHaveLength(0); + expect(executor.discarded).toEqual([refusedProof]); }); it('returns capability-lost without dispatch on terminal ownership', async () => { const session = await build().open(ACTIVATION); ownership.invalidate('shutdown'); - expect(await session.applyVerified({})).toEqual({ outcome: 'capability-lost' }); + const refusedProof = Object.freeze({ proof: 'terminal-lease' }); + expect(await session.applyVerified(refusedProof)).toEqual({ outcome: 'capability-lost' }); expect(executor.calls).toHaveLength(0); + expect(executor.discarded).toEqual([refusedProof]); }); it('seals admission into reconciling after an indeterminate dispatch', async () => { @@ -1088,11 +1769,13 @@ describe('system-record lane session lifecycle V1', () => { // No further work is admitted against a generation whose last write may // or may not have committed. executor.outcome = { outcome: 'applied', stateRevision: '2', appliedStateDigest: `0x${'b'.repeat(64)}` }; - expect(await session.applyVerified({})).toEqual({ + const refusedProof = Object.freeze({ proof: 'reconciling' }); + expect(await session.applyVerified(refusedProof)).toEqual({ outcome: 'deferred', reason: 'generation-changed', }); expect(executor.calls).toHaveLength(1); + expect(executor.discarded).toEqual([refusedProof]); }); it('downgrades a success to indeterminate when the child changed under dispatch', async () => { @@ -1121,4 +1804,411 @@ describe('system-record lane session lifecycle V1', () => { expect(await session.applyVerified({})).toEqual({ outcome: 'stale' }); }); }); + + describe('atomic uncertain-write recovery', () => { + const buildRecovery = (chosenBarrier: RecordingBarrier = barrier) => { + __resetSystemRecordControllerRegistrationForTests(); + const recoveryHandoff = new RecoveryHandoff(ownership); + recoveryHandoff.barrier = chosenBarrier; + const recoveryExecutor = new AtomicRecoveryExecutor(); + const controller = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: recoveryHandoff, + executor: recoveryExecutor, + barrier: chosenBarrier.run, + }); + return { controller, recoveryHandoff, recoveryExecutor, chosenBarrier }; + }; + + it('settles on a clean child at the existing epoch, then requires a fresh facade', async () => { + const { controller, recoveryHandoff, recoveryExecutor, chosenBarrier } = buildRecovery(); + const staleFacade = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryHandoff.recoveryDeadlines.length = 0; + const recoveryStartedAt = performance.now(); + + const result = await staleFacade.applyVerified({}); + expect(result).toEqual({ outcome: 'indeterminate', recoveryGeneration: '1' }); + expect(recoveryExecutor.registration?.ownership).toBeTruthy(); + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'2'.repeat(64)}`, + }); + + expect(recoveryHandoff.calls).toEqual([ + 'stopAndProveOwnedChildDead', + 'destroyClient', + 'awaitRetiredWork', + 'startAndProveCleanGeneration', + 'createRecoveryRuntime', + ]); + expect(chosenBarrier.purposes).toEqual([ + 'system-record.enable', + 'system-record.recovery', + ]); + expect(recoveryExecutor.recoveredRuntime?.client.childGeneration).toBe('2'); + const deadlines = recoveryHandoff.recoveryDeadlines.map(({ value }) => value); + expect(recoveryHandoff.recoveryDeadlines.map(({ phase }) => phase)).toEqual([ + 'stop', 'destroy', 'drain', 'start', 'exact-read', + ]); + expect(new Set(deadlines).size).toBe(1); + expect(deadlines[0]).toBeGreaterThan(recoveryStartedAt); + expect(deadlines[0]).toBeLessThanOrEqual(recoveryStartedAt + 30_100); + // Receipt/state reconciliation MUST precede any epoch rotation. Rotating + // first would make the exact old-epoch receipt unobservable. + expect(recoveryHandoff.calls).not.toContain('rotateMaterializationEpoch'); + expect(staleFacade.state).toBe('enabled'); + expect(staleFacade.activationGeneration).toBe('1'); + expect(await staleFacade.applyVerified({})).toEqual({ + outcome: 'deferred', + reason: 'generation-changed', + }); + + const freshFacade = await controller.open(ACTIVATION); + expect(freshFacade.activationGeneration).toBe('1'); + recoveryExecutor.settlement = 'no-mutation'; + expect(await freshFacade.applyVerified({})).toEqual({ outcome: 'stale' }); + expect(recoveryExecutor.calls.at(-1)).toEqual(expect.objectContaining({ + childGeneration: '2', + materializationEpoch: '1', + })); + }); + + it('accepts an attributable exact result that returns after its dispatch deadline', async () => { + const { controller, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryExecutor.parkReconcile(); + + const apply = session.applyVerified({}); + await recoveryExecutor.reconcileReached; + const runtime = recoveryExecutor.recoveredRuntime; + expect(runtime?.assertAttributable()).toBe(true); + const now = vi.spyOn(performance, 'now') + .mockReturnValue((runtime?.absoluteDeadlineMs ?? 0) + 1); + try { + recoveryExecutor.releaseReconcile(); + await apply; + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'2'.repeat(64)}`, + }); + } finally { + now.mockRestore(); + } + expect(session.state).toBe('enabled'); + }); + + it('uses retained terminal cleanup when a late exact result is unavailable', async () => { + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryExecutor.recoveryResolution = { resolution: 'unavailable' }; + recoveryExecutor.parkReconcile(); + + const apply = session.applyVerified({}); + await recoveryExecutor.reconcileReached; + const runtime = recoveryExecutor.recoveredRuntime; + const now = vi.spyOn(performance, 'now') + .mockReturnValue((runtime?.absoluteDeadlineMs ?? 0) + 1); + try { + recoveryExecutor.releaseReconcile(); + await apply; + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'unavailable', + }); + } finally { + now.mockRestore(); + } + + expect(recoveryHandoff.recoveryDeadlines.slice(-3)).toEqual([ + { phase: 'stop', value: undefined }, + { phase: 'destroy', value: undefined }, + { phase: 'drain', value: undefined }, + ]); + await new Promise((resolve) => setImmediate(resolve)); + expect(session.state).toBe('unavailable'); + }); + + it('retains recovery ownership after a transition timeout until late settlement', async () => { + const timeoutBarrier = new TransitionTimeoutBarrier(); + const { controller, recoveryExecutor } = buildRecovery(timeoutBarrier); + const session = await controller.open(ACTIVATION); + recoveryExecutor.parkReconcile(); + + await session.applyVerified({}); + await recoveryExecutor.reconcileReached; + let settled = false; + void recoveryExecutor.registration?.completion.then(() => { settled = true; }); + timeoutBarrier.timeout('system-record.recovery'); + for (let i = 0; i < 10; i += 1) await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(session.state).toBe('reconciling'); + + recoveryExecutor.releaseReconcile(); + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'applied', + stateRevision: '2', + appliedStateDigest: `0x${'2'.repeat(64)}`, + }); + expect(session.state).toBe('enabled'); + }); + + it('keeps a never-settling timed-out recovery and shutdown claimed', async () => { + const timeoutBarrier = new TransitionTimeoutBarrier(); + const { controller, recoveryExecutor } = buildRecovery(timeoutBarrier); + const session = await controller.open(ACTIVATION); + recoveryExecutor.parkReconcile(); + + await session.applyVerified({}); + await recoveryExecutor.reconcileReached; + let recoverySettled = false; + void recoveryExecutor.registration?.completion.then(() => { recoverySettled = true; }); + timeoutBarrier.timeout('system-record.recovery'); + const shutdownState = { settled: false }; + void session.close('shutdown').then( + () => { shutdownState.settled = true; }, + () => { shutdownState.settled = true; }, + ); + for (let i = 0; i < 10; i += 1) await new Promise((resolve) => setImmediate(resolve)); + + expect(recoverySettled).toBe(false); + expect(shutdownState.settled).toBe(false); + expect(session.state).toBe('shutdown'); + expect(() => + createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor: new StubExecutor(), + barrier: barrier.run, + }), + ).toThrow(SystemRecordControllerRegistrationError); + }); + + it('attaches uncertainty to a disable barrier that already owns the close', async () => { + const gatedBarrier = new GateBarrier(); + gatedBarrier.gate('system-record.disable'); + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(gatedBarrier); + const session = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryExecutor.parkBeforeRegistration(); + + const apply = session.applyVerified({}); + await recoveryExecutor.registrationReached; + const disable = session.close('disable'); + expect(session.state).toBe('disabling'); + await gatedBarrier.reached('system-record.disable'); + + recoveryExecutor.releaseRegistration(); + await expect(apply).resolves.toEqual({ outcome: 'indeterminate', recoveryGeneration: '1' }); + // Registration joined the already-enqueued close; a second recovery + // barrier behind disable would return too late and permit legacy bypass. + expect(gatedBarrier.purposes).toEqual(['system-record.enable']); + gatedBarrier.release('system-record.disable'); + await disable; + + expect(gatedBarrier.purposes).toEqual([ + 'system-record.enable', + 'system-record.disable', + ]); + expect(recoveryHandoff.calls).toEqual([ + 'stopAndProveOwnedChildDead', + 'destroyClient', + 'awaitRetiredWork', + 'startAndProveCleanGeneration', + 'createRecoveryRuntime', + 'rotateMaterializationEpoch', + ]); + expect(session.state).toBe('disabled'); + }); + + it('keeps disable intent latched when recovery was registered first', async () => { + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryExecutor.parkReconcile(); + + const apply = session.applyVerified({}); + await recoveryExecutor.reconcileReached; + expect(session.state).toBe('reconciling'); + const disable = session.close('disable'); + expect(session.state).toBe('disabling'); + + recoveryExecutor.releaseReconcile(); + await apply; + await disable; + expect(session.state).toBe('disabled'); + expect(recoveryHandoff.calls.at(-1)).toBe('rotateMaterializationEpoch'); + expect(recoveryHandoff.calls.filter((call) => call === 'startAndProveCleanGeneration')) + .toHaveLength(1); + }); + + it('attaches uncertainty to shutdown without restart, post-read, or epoch rotation', async () => { + const gatedBarrier = new GateBarrier(); + gatedBarrier.gate('system-record.shutdown'); + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(gatedBarrier); + const session = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryExecutor.parkBeforeRegistration(); + + const apply = session.applyVerified({}); + await recoveryExecutor.registrationReached; + const shutdown = session.close('shutdown'); + expect(session.state).toBe('shutdown'); + await gatedBarrier.reached('system-record.shutdown'); + + recoveryExecutor.releaseRegistration(); + await apply; + gatedBarrier.release('system-record.shutdown'); + await shutdown; + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'unavailable', + }); + + expect(recoveryHandoff.calls).toEqual([ + 'stopAndProveOwnedChildDead', + 'destroyClient', + 'awaitRetiredWork', + ]); + expect(recoveryHandoff.calls).not.toContain('startAndProveCleanGeneration'); + expect(recoveryHandoff.calls).not.toContain('createRecoveryRuntime'); + expect(recoveryHandoff.calls).not.toContain('rotateMaterializationEpoch'); + expect(session.state).toBe('shutdown'); + }); + + it('reports an attached shutdown that cannot prove the uncertain child dead', async () => { + const gatedBarrier = new GateBarrier(); + gatedBarrier.gate('system-record.shutdown'); + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(gatedBarrier); + const session = await controller.open(ACTIVATION); + recoveryExecutor.parkBeforeRegistration(); + + const apply = session.applyVerified({}); + await recoveryExecutor.registrationReached; + const shutdown = session.close('shutdown'); + await gatedBarrier.reached('system-record.shutdown'); + recoveryHandoff.failAt = 'stopAndProveOwnedChildDead'; + recoveryExecutor.releaseRegistration(); + await apply; + gatedBarrier.release('system-record.shutdown'); + + await expect(shutdown).rejects.toThrow(/could not prove uncertain write settled/); + let recoverySettled = false; + void recoveryExecutor.registration?.completion.then(() => { recoverySettled = true; }); + await new Promise((resolve) => setImmediate(resolve)); + expect(recoverySettled).toBe(false); + expect(session.state).toBe('shutdown'); + }); + + it('never republishes enabled when shutdown latches during exact settlement', async () => { + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryExecutor.parkReconcile(); + + const apply = session.applyVerified({}); + await recoveryExecutor.reconcileReached; + const shutdown = session.close('shutdown'); + expect(session.state).toBe('shutdown'); + recoveryExecutor.releaseReconcile(); + + await apply; + await shutdown; + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'unavailable', + }); + expect(session.state).toBe('shutdown'); + await expect(controller.open(ACTIVATION)).rejects.toThrow(/terminal/); + // The replacement was already started before shutdown intent, so the + // shutdown transition must perform one additional teardown for it. + expect(recoveryHandoff.calls.filter((call) => call === 'stopAndProveOwnedChildDead')) + .toHaveLength(2); + }); + + it('fails terminally closed when exact settlement is unavailable', async () => { + const { controller, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryExecutor.recoveryResolution = { resolution: 'unavailable' }; + + expect((await session.applyVerified({})).outcome).toBe('indeterminate'); + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'unavailable', + }); + // Completion resolves before the lifecycle tail publishes terminality; + // yield once to observe the fail-closed state transition. + await new Promise((resolve) => setImmediate(resolve)); + expect(session.state).toBe('unavailable'); + await expect(controller.open(ACTIVATION)).rejects.toThrow(/terminal/); + }); + + for (const failAt of [ + 'stopAndProveOwnedChildDead', + 'destroyClient', + 'awaitRetiredWork', + 'startAndProveCleanGeneration', + 'createRecoveryRuntime', + ] as const) { + it(`fails closed and retains ownership safely when recovery fails at ${failAt}`, async () => { + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryHandoff.failAt = failAt; + + expect((await session.applyVerified({})).outcome).toBe('indeterminate'); + if ( + failAt === 'stopAndProveOwnedChildDead' || + failAt === 'destroyClient' || + failAt === 'awaitRetiredWork' + ) { + let recoverySettled = false; + void recoveryExecutor.registration?.completion.then(() => { recoverySettled = true; }); + await new Promise((resolve) => setImmediate(resolve)); + expect(recoverySettled).toBe(false); + } else { + await expect(recoveryExecutor.registration?.completion).resolves.toEqual({ + resolution: 'unavailable', + }); + } + await new Promise((resolve) => setImmediate(resolve)); + + expect(session.state).toBe('unavailable'); + expect(recoveryHandoff.calls.some((call) => + call.startsWith('failManagedMutationsClosed:'))).toBe(true); + }); + } + + it('shutdown cancels and joins disable-attached exact recovery without a second teardown', async () => { + const gatedBarrier = new GateBarrier(); + gatedBarrier.gate('system-record.disable'); + const { controller, recoveryHandoff, recoveryExecutor } = buildRecovery(gatedBarrier); + const session = await controller.open(ACTIVATION); + recoveryHandoff.calls.length = 0; + recoveryExecutor.parkBeforeRegistration(); + recoveryExecutor.parkReconcileUntilAbort(); + + const apply = session.applyVerified({}); + await recoveryExecutor.registrationReached; + const disable = session.close('disable'); + await gatedBarrier.reached('system-record.disable'); + recoveryExecutor.releaseRegistration(); + await apply; + gatedBarrier.release('system-record.disable'); + await recoveryExecutor.reconcileReached; + + // No test release exists for the exact read. Shutdown must abort the + // lifecycle-owned signal, await that rejection, then reuse the recovery's + // physical-settlement token instead of stopping the replacement twice. + const shutdown = session.close('shutdown'); + await Promise.allSettled([disable, shutdown]); + + expect(recoveryExecutor.recoveredRuntime?.signal.aborted).toBe(true); + expect(recoveryHandoff.calls.filter((call) => + call === 'stopAndProveOwnedChildDead')).toHaveLength(2); + expect(gatedBarrier.purposes).toEqual([ + 'system-record.enable', + 'system-record.disable', + ]); + expect(session.state).toBe('shutdown'); + }); + }); }); diff --git a/packages/storage/test/system-record-next-state-v1.test.ts b/packages/storage/test/system-record-next-state-v1.test.ts new file mode 100644 index 0000000000..23bd45a458 --- /dev/null +++ b/packages/storage/test/system-record-next-state-v1.test.ts @@ -0,0 +1,856 @@ +import { readFileSync } from 'node:fs'; + +import { + keccak256, + SENTINEL_NO_PRIVATE_V10, + tripleContentV10, + V10MerkleTree, +} from '@origintrail-official/dkg-core'; +import { + buildAgentProfileVerificationClosureV1, + canonicalizeOwnedSubjectTableObjectV1, + canonicalizeSignedSystemRecordEnvelopeV1, + computeAgentProfileAuthorityTransitionDigestV1, + computeAgentProfileHeadObjectDigestV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordAppliedStateDigestV1, + computeSystemRecordMaterializationReceiptDigestV1, + computeSystemRecordStableKeyHashV1, + digestSystemRecordBytesV1, + EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, + SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES, + SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES, + SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES, + type AgentProfileActiveHeadObjectV1, + type AgentProfileAuthorityTransitionV1, + type NetworkIdV1, + type SignedAgentProfileAuthorityTransitionEnvelopeV1, + type SignedAgentProfileHeadEnvelopeV1, + type SystemRecordAppliedStatePresentV1, + type SystemRecordCapacityStateV1, + type SystemRecordMaterializationReceiptV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { describe, expect, it } from 'vitest'; + +import { + buildSystemRecordConditionalApplyUpdateV1, +} from '../src/system-record-apply-command-v1-internal.js'; +import { + assertAuthenticSystemRecordActiveReplacementCompleteV1, + deriveSystemRecordActiveReplacementV1, + type SystemRecordActiveReplacementCompleteV1, + type SystemRecordActiveReplacementReadyV1, +} from '../src/system-record-next-state-v1-internal.js'; +import { + buildSystemRecordReservedStateQuadsV1, + systemRecordEpochSubjectV1, + systemRecordRootClaimSubjectV1, + SYSTEM_RECORD_V1_PREDICATES, +} from '../src/system-record-rdf-schema-v1-internal.js'; +import { + decodeSystemRecordAppliedSnapshotV1, + type SystemRecordAppliedSnapshotV1, +} from '../src/system-record-state-snapshot-v1-internal.js'; +import { + createSystemRecordVerifiedReplacementRegistryV1, + type SystemRecordVerifiedReplacementFactsV1, +} from '../src/system-record-verified-replacement-v1-internal.js'; +import { SYSTEM_RECORD_V1_STATE_GRAPH } from '../src/internal-graph-policy.js'; + +interface Vectors { + readonly variants: { + readonly active: { readonly object: AgentProfileActiveHeadObjectV1 }; + readonly coSignedTransition: { readonly object: AgentProfileAuthorityTransitionV1 }; + }; + readonly signed: { + readonly activeEip191: { readonly envelope: SignedAgentProfileHeadEnvelopeV1 }; + readonly coSignedTransitionEip191: { + readonly envelope: SignedAgentProfileAuthorityTransitionEnvelopeV1; + }; + }; +} + +const vectors = JSON.parse(readFileSync(new URL( + '../../core/test/fixtures/system-record-v1/vectors.json', + import.meta.url, +), 'utf8')) as Vectors; +const EPOCH = '13'; +const BUNDLE_BYTES = new TextEncoder().encode('next-state-verified-profile-bundle'); +const BUNDLE_DIGEST = digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + BUNDLE_BYTES, +); +const RDF_TYPE = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type'; +const CAPABILITY_LINK = 'https://eips.ethereum.org/erc-8004#capabilities'; +const OFFERING_LINK = 'https://dkg.origintrail.io/skill#offersSkill'; + +const initialProjection = smallProjection(vectors.variants.active.object.rootSubject); +const INITIAL = prepareHead(vectors.variants.active.object, initialProjection); +const INITIAL_FACTS = await factsFor(INITIAL, initialProjection); + +describe('system-record active next-state derivation', () => { + it('cold-applies an initial head into one complete authentic deterministic tuple', () => { + const snapshot = absentSnapshot(INITIAL.networkId); + const first = deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot, + observedRootClaimQuads: [], + }); + const second = deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot, + observedRootClaimQuads: [], + }); + const ready = expectReady(first); + const repeated = expectReady(second); + + expect(ready.nextAppliedState).toMatchObject({ + stateRevision: '1', status: 'active', + }); + expect(ready.nextAppliedState.transitionLineage).toHaveLength(0); + expect(ready.next.headVersion).toBe('0'); + expect(ready.next.capacityState).toMatchObject({ revision: '1', liveRecordCount: '1' }); + expect(ready.previousReservedQuads).toHaveLength(1); + expect(ready.nextReservedQuads).toHaveLength(15); + expect(ready.requiredAbsentReservedSubjects).toHaveLength(4); + expect(ready.conditionalApply.previousReservedQuads).toBe(ready.previousReservedQuads); + expect(ready.postReadExpectation.reservedQuads).toBe(ready.nextReservedQuads); + expect(ready.success).toEqual({ + stateRevision: '1', + appliedStateDigest: computeSystemRecordAppliedStateDigestV1(ready.nextAppliedState), + }); + expect(ready.next.receiptDigest).toBe( + computeSystemRecordMaterializationReceiptDigestV1(ready.next.receipt), + ); + expect(repeated.next.receiptDigest).toBe(ready.next.receiptDigest); + expect(repeated.nextReservedQuads).toEqual(ready.nextReservedQuads); + expect(() => assertAuthenticSystemRecordActiveReplacementCompleteV1(ready)).not.toThrow(); + expect(() => assertAuthenticSystemRecordActiveReplacementCompleteV1({ ...ready })) + .toThrow(/verified state derivation/); + expect(() => deriveSystemRecordActiveReplacementV1({ + facts: { ...INITIAL_FACTS }, + snapshot, + observedRootClaimQuads: [], + })).toThrow(/not produced by this registry/); + }); + + it('cold-applies a complete noninitial active closure', async () => { + const rotated = await rotatedFixture(INITIAL); + const result = expectReady(deriveSystemRecordActiveReplacementV1({ + facts: rotated.facts, + snapshot: absentSnapshot(INITIAL.networkId), + observedRootClaimQuads: [], + })); + expect(result.nextAppliedState).toMatchObject({ + currentRoot: rotated.head.rootSubject, + historicalRoots: [INITIAL.rootSubject], + }); + expect(result.nextAppliedState.transitionLineage).toHaveLength(1); + expect(result.next.headVersion).toBe('0'); + expect(result.requiredAbsentReservedSubjects).toHaveLength(5); + expect(result.next.rootClaimQuads).toHaveLength(6); + }); + + it('returns a complete authentic already-applied result for one equal head', () => { + const cold = coldReady(); + const snapshot = snapshotFrom(cold); + const result = deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + }); + expect(result.outcome).toBe('already-applied'); + if (result.outcome !== 'already-applied') throw new Error('expected already-applied'); + expect(result.nextReservedQuads).toEqual(result.previousReservedQuads); + expect(result.success).toEqual(cold.success); + expect(result.conditionalApply.nextProjectionQuads).toBe(INITIAL_FACTS.projectionQuads); + 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, { + ...cold.next.appliedState, + projectionDigest: `0x${'ab'.repeat(32)}`, + }); + expect(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot: inconsistent, + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'deferred', reason: 'verified-state-mismatch' }); + }); + + it('accepts same-authority higher-version fast-forward and rejects stale/equal-version fork', async () => { + const cold = coldReady(); + const snapshot = snapshotFrom(cold); + const fastHead = prepareHead({ + ...INITIAL, + version: '2', + previousHeadDigest: computeAgentProfileHeadObjectDigestV1(INITIAL), + issuedAt: '2026-08-05T12:20:00Z', + validUntil: '2026-08-08T12:20:00Z', + }, initialProjection); + const fastFacts = await factsFor(fastHead, initialProjection, [INITIAL]); + const fast = expectReady(deriveSystemRecordActiveReplacementV1({ + facts: fastFacts, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })); + expect(fast.next.headVersion).toBe('2'); + + const fastSnapshot = snapshotFrom(fast); + expect(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot: fastSnapshot, + observedRootClaimQuads: fast.next.rootClaimQuads, + })).toEqual({ outcome: 'stale' }); + + const forkHead = prepareHead({ + ...INITIAL, + issuedAt: '2026-08-05T12:00:01Z', + validUntil: '2026-08-06T12:00:01Z', + }, initialProjection); + const forkFacts = await factsFor(forkHead, initialProjection); + expect(deriveSystemRecordActiveReplacementV1({ + facts: forkFacts, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'deferred', reason: 'authority-fork' }); + }); + + it('accepts exact +1 authority progression and rejects wrong predecessor and >+1', async () => { + const cold = coldReady(); + const snapshot = snapshotFrom(cold); + const rotated = await rotatedFixture(INITIAL); + const accepted = expectReady(deriveSystemRecordActiveReplacementV1({ + facts: rotated.facts, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })); + expect(accepted.nextAppliedState.historicalRoots).toEqual([INITIAL.rootSubject]); + expect(accepted.requiredAbsentReservedSubjects).toContain( + systemRecordRootClaimSubjectV1(INITIAL.networkId, rotated.head.rootSubject), + ); + + const alternatePrior = prepareHead({ + ...INITIAL, + version: '1', + previousHeadDigest: computeAgentProfileHeadObjectDigestV1(INITIAL), + issuedAt: '2026-08-05T12:10:00Z', + validUntil: '2026-08-08T12:10:00Z', + }, initialProjection); + const wrong = await rotatedFixture(alternatePrior, [INITIAL]); + expect(deriveSystemRecordActiveReplacementV1({ + facts: wrong.facts, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'deferred', reason: 'authority-history-mismatch' }); + + const twice = await twiceRotatedFixture(); + expect(deriveSystemRecordActiveReplacementV1({ + facts: twice.facts, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'deferred', reason: 'authority-history-mismatch' }); + }); + + it('classifies root collision and aggregate capacity refusal with zero prepared output', () => { + const cold = coldReady(); + const foreignClaim = cold.next.rootClaimQuads.map((quad) => ( + quad.predicate === SYSTEM_RECORD_V1_PREDICATES.claimedBy + ? Object.freeze({ ...quad, object: 'urn:test:foreign-record' }) + : quad + )); + const collision = deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot: absentSnapshot(INITIAL.networkId), + observedRootClaimQuads: foreignClaim, + }); + expect(collision).toMatchObject({ outcome: 'root-collision' }); + expect(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot: absentSnapshot(INITIAL.networkId), + observedRootClaimQuads: foreignClaim.slice(0, 1), + })).toEqual({ outcome: 'deferred', reason: 'root-state-changed' }); + + const capacity = { + objectType: 'system-record-capacity-state', kind: 'agents', networkId: INITIAL.networkId, + revision: '9', liveRecordCount: '8192', + stateBytes: SYSTEM_RECORD_MAX_APPLIED_AGGREGATE_BYTES.toString(), + tableBytes: '0', projectionBytes: '0', projectionQuads: '0', + } as const; + const saturated = absentSnapshotWithCapacity(cold, capacity); + expect(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot: saturated, + observedRootClaimQuads: [], + })).toEqual({ outcome: 'capacity-exhausted', reason: 'aggregate-cap' }); + }); + + it('refuses state and capacity revision overflow before producing a command', async () => { + const cold = coldReady(); + const nextHead = prepareHead({ + ...INITIAL, + version: '1', + previousHeadDigest: computeAgentProfileHeadObjectDigestV1(INITIAL), + issuedAt: '2026-08-05T12:10:00Z', + validUntil: '2026-08-08T12:10:00Z', + }, initialProjection); + const nextFacts = await factsFor(nextHead, initialProjection, [INITIAL]); + const maxU64 = '18446744073709551615'; + + expect(deriveSystemRecordActiveReplacementV1({ + facts: nextFacts, + snapshot: snapshotWithAppliedState(cold, { + ...cold.next.appliedState, + stateRevision: maxU64, + }), + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'capacity-exhausted', reason: 'state-revision-overflow' }); + + expect(deriveSystemRecordActiveReplacementV1({ + facts: nextFacts, + snapshot: snapshotWithCapacityState(cold, { + ...cold.next.capacityState, + revision: maxU64, + }), + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'capacity-exhausted', reason: 'capacity-revision-overflow' }); + }); + + it('refuses a 2,049-subject replacement union before producing a command', async () => { + const priorData = largeProjection(INITIAL.rootSubject, 'capability', 1_024); + const priorHead = prepareHead(INITIAL, priorData.quads, priorData.subjects); + const priorFacts = await factsFor(priorHead, priorData.quads, [], [], priorData.subjects); + const priorReady = expectReady(deriveSystemRecordActiveReplacementV1({ + facts: priorFacts, + snapshot: absentSnapshot(INITIAL.networkId), + observedRootClaimQuads: [], + })); + const nextData = largeProjection(INITIAL.rootSubject, 'offering', 1_024); + const nextHead = prepareHead({ + ...priorHead, + version: '1', + previousHeadDigest: computeAgentProfileHeadObjectDigestV1(priorHead), + issuedAt: '2026-08-05T12:10:00Z', + validUntil: '2026-08-08T12:10:00Z', + }, nextData.quads, nextData.subjects); + const nextFacts = await factsFor( + nextHead, + nextData.quads, + [priorHead], + [], + nextData.subjects, + [priorData.subjects], + ); + expect(deriveSystemRecordActiveReplacementV1({ + facts: nextFacts, + snapshot: snapshotFrom(priorReady), + observedRootClaimQuads: priorReady.next.rootClaimQuads, + })).toEqual({ outcome: 'capacity-exhausted', reason: 'subject-union-cap' }); + }, 20_000); + + it( + 'prepares the exact 10,000-quad protocol maximum within the 4 MiB request bound', async () => { + const projection = maximumProjection(INITIAL.rootSubject); + const head = prepareHead(INITIAL, projection); + const facts = await factsFor(head, projection); + const ready = expectReady(deriveSystemRecordActiveReplacementV1({ + facts, + snapshot: absentSnapshot(INITIAL.networkId), + observedRootClaimQuads: [], + })); + const update = buildSystemRecordConditionalApplyUpdateV1(ready); + + expect(ready.nextProjectionQuads).toHaveLength(10_000); + expect(update.requestBytes).toBe(Buffer.byteLength(update.sparql, 'utf8')); + expect(update.requestBytes).toBeLessThanOrEqual(SYSTEM_RECORD_MAX_ATOMIC_SPARQL_REQUEST_BYTES); + expect(update.requestBytes * 3).toBeLessThanOrEqual(SYSTEM_RECORD_MAX_ATOMIC_PREPARED_BYTES); + }, 30_000, + ); + + it('defers quarantined, dirty, and tombstone local states without producing write facts', () => { + const cold = coldReady(); + for (const status of ['quarantined', 'dirty', 'tombstone'] as const) { + const snapshot = nonActiveSnapshot(cold, status); + expect(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot, + observedRootClaimQuads: cold.next.rootClaimQuads, + })).toEqual({ outcome: 'deferred', reason: 'non-active-state' }); + } + }); +}); + +function coldReady(): SystemRecordActiveReplacementReadyV1 { + return expectReady(deriveSystemRecordActiveReplacementV1({ + facts: INITIAL_FACTS, + snapshot: absentSnapshot(INITIAL.networkId), + observedRootClaimQuads: [], + })); +} + +function expectReady(value: ReturnType) { + expect(value.outcome).toBe('ready'); + if (value.outcome !== 'ready') throw new Error(`expected ready, got ${value.outcome}`); + return value; +} + +function absentSnapshot(networkId: string): SystemRecordAppliedSnapshotV1 { + return decodeSystemRecordAppliedSnapshotV1({ + networkId, + stableKeyHash: computeStableKey(INITIAL), + materializationEpoch: EPOCH, + quads: [{ + subject: systemRecordEpochSubjectV1(networkId), + predicate: SYSTEM_RECORD_V1_PREDICATES.materializationEpoch, + object: `"${EPOCH}"`, + graph: SYSTEM_RECORD_V1_STATE_GRAPH, + }], + }); +} + +function absentSnapshotWithCapacity( + ready: SystemRecordActiveReplacementReadyV1, + capacityState: SystemRecordCapacityStateV1, +): SystemRecordAppliedSnapshotV1 { + const quads = buildSystemRecordReservedStateQuadsV1({ + appliedState: ready.next.appliedState, + headVersion: ready.next.headVersion, + ownedSubjectTable: ready.next.ownedSubjectTable, + rootClaimSet: ready.next.rootClaimSet, + capacityState, + receipt: ready.next.receipt, + }); + return decodeSystemRecordAppliedSnapshotV1({ + networkId: INITIAL.networkId, + stableKeyHash: computeStableKey(INITIAL), + materializationEpoch: EPOCH, + quads: [...quads.capacity, ...quads.epoch], + }); +} + +function snapshotFrom(value: SystemRecordActiveReplacementCompleteV1): SystemRecordAppliedSnapshotV1 { + const rootKeys = new Set(value.next.rootClaimQuads.map(quadKey)); + return decodeSystemRecordAppliedSnapshotV1({ + networkId: value.next.appliedState.networkId, + stableKeyHash: value.next.appliedState.stableKeyHash, + materializationEpoch: value.next.materializationEpoch, + quads: value.nextReservedQuads.filter((quad) => !rootKeys.has(quadKey(quad))), + }); +} + +function snapshotWithAppliedState( + ready: SystemRecordActiveReplacementReadyV1, + appliedState: SystemRecordAppliedStatePresentV1, +): SystemRecordAppliedSnapshotV1 { + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState); + const receipt: SystemRecordMaterializationReceiptV1 = { + ...ready.next.receipt, + appliedStateDigest, + headDigest: appliedState.headDigest, + stateRevision: appliedState.stateRevision, + }; + const quads = buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: ready.next.headVersion, + ownedSubjectTable: ready.next.ownedSubjectTable, + rootClaimSet: ready.next.rootClaimSet, + capacityState: ready.next.capacityState, + receipt, + }); + return decodeSystemRecordAppliedSnapshotV1({ + networkId: INITIAL.networkId, + stableKeyHash: computeStableKey(INITIAL), + materializationEpoch: EPOCH, + quads: [...quads.record, ...quads.capacity, ...quads.epoch, ...quads.receipt], + }); +} + +function snapshotWithCapacityState( + ready: SystemRecordActiveReplacementReadyV1, + capacityState: SystemRecordCapacityStateV1, +): SystemRecordAppliedSnapshotV1 { + const quads = buildSystemRecordReservedStateQuadsV1({ + appliedState: ready.next.appliedState, + headVersion: ready.next.headVersion, + ownedSubjectTable: ready.next.ownedSubjectTable, + rootClaimSet: ready.next.rootClaimSet, + capacityState, + receipt: ready.next.receipt, + }); + return decodeSystemRecordAppliedSnapshotV1({ + networkId: INITIAL.networkId, + stableKeyHash: computeStableKey(INITIAL), + materializationEpoch: EPOCH, + quads: [...quads.record, ...quads.capacity, ...quads.epoch, ...quads.receipt], + }); +} + +function nonActiveSnapshot( + ready: SystemRecordActiveReplacementReadyV1, + status: 'quarantined' | 'dirty' | 'tombstone', +): SystemRecordAppliedSnapshotV1 { + const base = ready.next.appliedState; + let appliedState: SystemRecordAppliedStatePresentV1; + let table = ready.next.ownedSubjectTable; + let capacityState = ready.next.capacityState; + if (status === 'quarantined') { + appliedState = { + ...base, + status, + conflictSidecarIntentOperation: 'deferred', + conflictSidecarIntentEvidenceDigest: `0x${'ee'.repeat(32)}`, + conflictSidecarIntentStateRevision: base.stateRevision, + }; + } else if (status === 'dirty') { + appliedState = { ...base, status }; + } else { + table = Object.freeze([]); + appliedState = { + ...base, + status, + projectionDigest: SYSTEM_RECORD_EMPTY_PROJECTION_DIGEST_V1, + projectionBytes: '0', + projectionQuads: '0', + ownedSubjectTableDigest: EMPTY_OWNED_SUBJECT_TABLE_DIGEST_V1, + ownedSubjectCount: '0', + ownedSubjectTableBytes: '0', + accountedBytes: '65536', + }; + capacityState = { + ...capacityState, + tableBytes: '0', projectionBytes: '0', projectionQuads: '0', + }; + } + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState); + const receipt: SystemRecordMaterializationReceiptV1 = { + ...ready.next.receipt, + appliedStateDigest, + headDigest: appliedState.headDigest, + }; + const quads = buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: ready.next.headVersion, + ownedSubjectTable: table, + rootClaimSet: ready.next.rootClaimSet, + capacityState, + receipt, + }); + return decodeSystemRecordAppliedSnapshotV1({ + networkId: INITIAL.networkId, + stableKeyHash: computeStableKey(INITIAL), + materializationEpoch: EPOCH, + quads: [...quads.record, ...quads.capacity, ...quads.epoch, ...quads.receipt], + }); +} + +async function rotatedFixture( + prior: AgentProfileActiveHeadObjectV1, + earlier: readonly AgentProfileActiveHeadObjectV1[] = [], +) { + const template = vectors.variants.coSignedTransition.object; + const transition: AgentProfileAuthorityTransitionV1 = { + ...template, + priorAuthoritySequence: prior.authoritySequence, + nextAuthoritySequence: (BigInt(prior.authoritySequence) + 1n).toString(), + priorHeadDigest: computeAgentProfileHeadObjectDigestV1(prior), + priorEvmIssuer: prior.evmIssuer, + issuedAt: '2026-08-07T12:00:00Z', + } as AgentProfileAuthorityTransitionV1; + const transitionDigest = computeAgentProfileAuthorityTransitionDigestV1(transition); + const projection = smallProjection(transition.nextRoot); + const head = prepareHead({ + ...prior, + authoritySequence: transition.nextAuthoritySequence, + version: '0', + acceptedTransitionDigest: transitionDigest, + evmIssuer: transition.nextEvmIssuer, + rootSubject: transition.nextRoot, + issuedAt: '2026-08-07T12:01:00Z', + validUntil: '2026-08-10T12:01:00Z', + previousHeadDigest: undefined, + } as unknown as AgentProfileActiveHeadObjectV1, projection); + return { + head, + transition, + facts: await factsFor(head, projection, [prior, ...earlier], [transition]), + }; +} + +async function twiceRotatedFixture() { + const first = await rotatedFixture(INITIAL); + const nextIssuer = '0x3333333333333333333333333333333333333333'; + const transition: AgentProfileAuthorityTransitionV1 = { + ...first.transition, + priorAuthoritySequence: '1', + nextAuthoritySequence: '2', + priorHeadDigest: computeAgentProfileHeadObjectDigestV1(first.head), + priorEvmIssuer: first.head.evmIssuer, + nextEvmIssuer: nextIssuer, + nextRoot: `did:dkg:agent:${nextIssuer}`, + issuedAt: '2026-08-08T12:00:00Z', + }; + const projection = smallProjection(transition.nextRoot); + const head = prepareHead({ + ...first.head, + authoritySequence: '2', + version: '0', + acceptedTransitionDigest: computeAgentProfileAuthorityTransitionDigestV1(transition), + evmIssuer: nextIssuer, + rootSubject: transition.nextRoot, + issuedAt: '2026-08-08T12:01:00Z', + validUntil: '2026-08-11T12:01:00Z', + previousHeadDigest: undefined, + } as unknown as AgentProfileActiveHeadObjectV1, projection); + return { + head, + facts: await factsFor( + head, + projection, + [first.head, INITIAL], + [first.transition, transition], + ), + }; +} + +async function factsFor( + head: AgentProfileActiveHeadObjectV1, + projection: readonly Readonly<{ subject: string; predicate: string; object: string; graph: string }>[], + history: readonly AgentProfileActiveHeadObjectV1[] = [], + transitions: readonly AgentProfileAuthorityTransitionV1[] = [], + table: readonly string[] = [head.rootSubject], + historyTables: readonly (readonly string[])[] = [], +): Promise { + const authority = await mintAuthority(head, table, history, historyTables, transitions); + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const sessionIdentity = Object.freeze(Object.create(null) as object); + const bindings = { + networkId: head.networkId as NetworkIdV1, + kind: 'agents' as const, + mode: 'shadow' as const, + sessionIdentity, + activationGeneration: '7', + childGeneration: '11', + materializationEpoch: EPOCH, + }; + const bytes = canonicalProjectionBytes(projection); + const handle = registry.issuer.issueActive({ + ...bindings, + admittedDeadlineMs: 42_000, + head, + verifiedAuthoritySummary: authority, + canonicalProjectionBytes: bytes, + projectionQuads: projection, + ownedSubjectTable: table, + }); + return registry.consumer.consume(handle, bindings); +} + +async function mintAuthority( + current: AgentProfileActiveHeadObjectV1, + currentTable: readonly string[], + history: readonly AgentProfileActiveHeadObjectV1[], + historyTables: readonly (readonly string[])[], + transitions: readonly AgentProfileAuthorityTransitionV1[], +) { + const artifacts = new Map(); + for (const [index, head] of [current, ...history].entries()) { + const table = index === 0 ? currentTable : historyTables[index - 1] ?? [head.rootSubject]; + const digest = computeAgentProfileHeadObjectDigestV1(head); + artifacts.set(`agent-profile-head:${digest}`, { + objectKind: 'agent-profile-head', digest, canonicalBytes: headEnvelopeBytes(head), + }); + artifacts.set(`owned-subject-table:${head.ownedSubjectTableDigest}`, { + objectKind: 'owned-subject-table', + digest: head.ownedSubjectTableDigest, + canonicalBytes: canonicalizeOwnedSubjectTableObjectV1(head.rootSubject, table), + }); + } + for (const transition of transitions) { + const digest = computeAgentProfileAuthorityTransitionDigestV1(transition); + artifacts.set(`authority-transition:${digest}`, { + objectKind: 'authority-transition', digest, canonicalBytes: transitionEnvelopeBytes(transition), + }); + } + artifacts.set(`profile-bundle:${current.bundleDigest}`, { + objectKind: 'profile-bundle', digest: current.bundleDigest, canonicalBytes: BUNDLE_BYTES, + }); + const closure = await buildAgentProfileVerificationClosureV1( + computeAgentProfileHeadObjectDigestV1(current), + { + nowMs: Date.parse('2026-08-09T12:00:00Z'), + resolve: async (reference) => artifacts.get(`${reference.objectKind}:${reference.digest}`), + verifyAuthorityEnvelope: () => true, + verifyCurrentBundle: (_head, bytes) => Buffer.from(bytes).equals(Buffer.from(BUNDLE_BYTES)), + }, + ); + return closure.authoritySummary; +} + +function headEnvelopeBytes(head: AgentProfileActiveHeadObjectV1): Uint8Array { + const template = structuredClone(vectors.signed.activeEip191.envelope); + const envelope = { + ...template, + object: head, + objectDigest: computeAgentProfileHeadObjectDigestV1(head), + signatures: template.signatures.map((signature) => ({ + ...signature, + signer: signature.role === 'peer' ? head.peerId : head.evmIssuer, + })), + } as SignedAgentProfileHeadEnvelopeV1; + return canonicalizeSignedSystemRecordEnvelopeV1(envelope); +} + +function transitionEnvelopeBytes(transition: AgentProfileAuthorityTransitionV1): Uint8Array { + const template = structuredClone(vectors.signed.coSignedTransitionEip191.envelope); + const envelope = { + ...template, + object: transition, + objectDigest: computeAgentProfileAuthorityTransitionDigestV1(transition), + signatures: template.signatures.map((signature) => ({ + ...signature, + signer: signature.role === 'peer' + ? transition.peerId + : signature.role === 'prior-evm' + ? transition.priorEvmIssuer + : transition.nextEvmIssuer, + })), + } as SignedAgentProfileAuthorityTransitionEnvelopeV1; + return canonicalizeSignedSystemRecordEnvelopeV1(envelope); +} + +function prepareHead( + source: AgentProfileActiveHeadObjectV1, + projection: readonly Readonly<{ subject: string; predicate: string; object: string; graph: string }>[], + table: readonly string[] = [source.rootSubject], +): AgentProfileActiveHeadObjectV1 { + const { + previousHeadDigest, + acceptedTransitionDigest, + forkResolutionDigest, + ...required + } = source; + const bytes = canonicalProjectionBytes(projection); + const contentDigest = projectionContentDigest(projection); + const address = source.evmIssuer; + const history = { + ...(previousHeadDigest === undefined ? {} : { previousHeadDigest }), + ...(acceptedTransitionDigest === undefined ? {} : { + acceptedTransitionDigest, + }), + ...(forkResolutionDigest === undefined ? {} : { forkResolutionDigest }), + }; + return { + ...required, + ...history, + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1(source.rootSubject, table), + ownedSubjectCount: table.length.toString(), + projectionBytes: bytes.byteLength.toString(), + projectionQuads: projection.length.toString(), + contentDigest, + bundleDigest: BUNDLE_DIGEST, + graphScopedAuthorSeal: { + ...source.graphScopedAuthorSeal, + assertionMerkleRoot: contentDigest, + authorAddress: address, + kaUal: `did:dkg:${source.networkId}/${address}/7`, + reservedKaId: ((BigInt(address) << 96n) | 7n).toString(), + publicTripleCount: projection.length.toString(), + }, + } as AgentProfileActiveHeadObjectV1; +} + +function smallProjection(root: string) { + return sortProjection([ + { subject: root, predicate: RDF_TYPE, object: 'https://dkg.network/ontology#Agent', graph: '' }, + { subject: root, predicate: 'https://schema.org/description', object: '"b"', graph: '' }, + { subject: root, predicate: 'https://schema.org/name', object: '"a"', graph: '' }, + ]); +} + +function maximumProjection(root: string) { + const quads: Array<{ subject: string; predicate: string; object: string; graph: string }> = [ + { subject: root, predicate: RDF_TYPE, object: 'https://dkg.network/ontology#Agent', graph: '' }, + ]; + for (let index = 0; index < 9_999; index += 1) { + quads.push({ + subject: root, + predicate: 'https://schema.org/description', + object: `"maximum-${index.toString().padStart(4, '0')}"`, + graph: '', + }); + } + return sortProjection(quads); +} + +function largeProjection(root: string, kind: 'capability' | 'offering', count: number) { + const subjects = [root]; + const quads: Array<{ subject: string; predicate: string; object: string; graph: string }> = [ + { subject: root, predicate: 'https://schema.org/name', object: '"large"', graph: '' }, + ]; + for (let index = 1; index <= count; index += 1) { + const subject = `${root}/.well-known/genid/${kind === 'capability' ? 'cap' : 'offering'}${index}`; + subjects.push(subject); + quads.push({ + subject, + predicate: RDF_TYPE, + object: kind === 'capability' + ? 'https://eips.ethereum.org/erc-8004#Capability' + : 'https://dkg.origintrail.io/skill#SkillOffering', + graph: '', + }, { + subject: root, + predicate: kind === 'capability' ? CAPABILITY_LINK : OFFERING_LINK, + object: subject, + graph: '', + }); + } + subjects.sort(compareUtf8); + return { subjects: Object.freeze(subjects), quads: sortProjection(quads) }; +} + +function sortProjection>( + quads: readonly T[], +): readonly T[] { + return Object.freeze([...quads].sort((left, right) => Buffer.compare( + tripleContentV10(left.subject, left.predicate, left.object), + tripleContentV10(right.subject, right.predicate, right.object), + ))); +} + +function canonicalProjectionBytes( + quads: readonly Readonly<{ subject: string; predicate: string; object: string }>[], +): Uint8Array { + return new TextEncoder().encode(quads.map((quad) => ( + `${new TextDecoder().decode(tripleContentV10(quad.subject, quad.predicate, quad.object))}\n` + )).join('')); +} + +function projectionContentDigest( + quads: readonly Readonly<{ subject: string; predicate: string; object: string }>[], +): `0x${string}` { + const leaves = quads.map((quad) => keccak256( + tripleContentV10(quad.subject, quad.predicate, quad.object), + )); + const root = V10MerkleTree.computeKARoot(new V10MerkleTree(leaves).root, SENTINEL_NO_PRIVATE_V10); + return `0x${Buffer.from(root).toString('hex')}`; +} + +function computeStableKey(head: AgentProfileActiveHeadObjectV1) { + return computeSystemRecordStableKeyHashV1(head.networkId, head.peerId); +} + +function quadKey(quad: Readonly<{ subject: string; predicate: string; object: string; graph: string }>) { + return `${quad.graph}\u0000${quad.subject}\u0000${quad.predicate}\u0000${quad.object}`; +} + +function compareUtf8(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8')); +} diff --git a/packages/storage/test/system-record-rdf-schema-v1.test.ts b/packages/storage/test/system-record-rdf-schema-v1.test.ts new file mode 100644 index 0000000000..68a72058ea --- /dev/null +++ b/packages/storage/test/system-record-rdf-schema-v1.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeOwnedSubjectTableDigestV1, + computeSystemRecordAccountedBytesV1, + computeSystemRecordAppliedStateDigestV1, + computeSystemRecordRootClaimSetDigestV1, + computeSystemRecordStableKeyHashV1, + type OwnedSubjectTableObjectV1, + type SystemRecordAppliedStatePresentV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { + SYSTEM_RECORD_V1_AUTHORITATIVE_AGENTS_GRAPH, + SYSTEM_RECORD_V1_JSON_DATATYPE, + SYSTEM_RECORD_V1_PREDICATES, + buildSystemRecordReservedStateQuadsV1, + systemRecordCapacitySubjectV1, + systemRecordEpochSubjectV1, + systemRecordProjectionGraphV1, + systemRecordReceiptSubjectV1, + systemRecordRecordSubjectV1, + systemRecordRootClaimSubjectV1, +} from '../src/system-record-rdf-schema-v1-internal.js'; +import { + SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH, + SYSTEM_RECORD_V1_STATE_GRAPH, +} from '../src/internal-graph-policy.js'; + +const NETWORK = 'otp:20430'; +const PEER = '12D3KooWJ1TsijH7H5F74hfAD5XishQz3sxrmAtVY37GtNd9CqYf'; +const ROOT = 'did:dkg:agent:0x1111111111111111111111111111111111111111'; +const HEAD = `0x${'aa'.repeat(32)}` as const; +const PROJECTION = `0x${'bb'.repeat(32)}` as const; +const STABLE_KEY = computeSystemRecordStableKeyHashV1(NETWORK, PEER); +const TABLE = Object.freeze([ROOT]) as OwnedSubjectTableObjectV1; + +describe('system-record V1 reserved RDF schema', () => { + it('derives fixed safe subjects and projection graphs without caller IRIs', () => { + expect(systemRecordProjectionGraphV1('shadow')).toBe(SYSTEM_RECORD_V1_SHADOW_AGENTS_GRAPH); + expect(systemRecordProjectionGraphV1('authoritative')).toBe( + SYSTEM_RECORD_V1_AUTHORITATIVE_AGENTS_GRAPH, + ); + expect(() => systemRecordProjectionGraphV1('other' as never)).toThrow(/mode/); + + const values = [ + systemRecordRecordSubjectV1(NETWORK, STABLE_KEY), + systemRecordCapacitySubjectV1(NETWORK), + systemRecordEpochSubjectV1(NETWORK), + systemRecordReceiptSubjectV1(NETWORK, STABLE_KEY), + systemRecordRootClaimSubjectV1(NETWORK, ROOT), + ]; + expect(new Set(values).size).toBe(values.length); + expect(values.every((value) => value.startsWith('urn:dkg:system-record-v1:'))).toBe(true); + expect(() => systemRecordRecordSubjectV1(NETWORK, `0x${'A'.repeat(64)}`)).toThrow(/digest/); + }); + + it('encodes one mutually bound canonical state/table/claim/capacity/receipt set', () => { + const rootClaimSet = { + objectType: 'system-record-root-claim-set', kind: 'agents', networkId: NETWORK, + stableKeyHash: STABLE_KEY, currentRoot: ROOT, historicalRoots: [], + } as const; + const appliedState = activeState(computeSystemRecordRootClaimSetDigestV1(rootClaimSet)); + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState); + const quads = buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: '0', + ownedSubjectTable: TABLE, + rootClaimSet, + capacityState: { + objectType: 'system-record-capacity-state', kind: 'agents', networkId: NETWORK, + revision: '1', liveRecordCount: '1', stateBytes: '65536', + tableBytes: appliedState.ownedSubjectTableBytes, + projectionBytes: appliedState.projectionBytes, + projectionQuads: appliedState.projectionQuads, + }, + receipt: { + objectType: 'system-record-materialization-receipt', kind: 'agents', networkId: NETWORK, + stableKeyHash: STABLE_KEY, stateRevision: '1', appliedStateDigest, + headDigest: HEAD, materializationEpoch: '2', + }, + }); + + const all = [...quads.record, ...quads.capacity, ...quads.epoch, ...quads.receipt, ...quads.rootClaims]; + expect(all).toHaveLength(15); + expect(all.every((quad) => quad.graph === SYSTEM_RECORD_V1_STATE_GRAPH)).toBe(true); + expect(all.every((quad) => Object.isFrozen(quad))).toBe(true); + expect(Object.isFrozen(quads.record)).toBe(true); + expect(quads.record.find((quad) => quad.predicate === SYSTEM_RECORD_V1_PREDICATES.appliedState)?.object) + .toContain(`^^<${SYSTEM_RECORD_V1_JSON_DATATYPE}>`); + expect(quads.rootClaims).toEqual(expect.arrayContaining([ + expect.objectContaining({ + subject: systemRecordRootClaimSubjectV1(NETWORK, ROOT), + predicate: SYSTEM_RECORD_V1_PREDICATES.claimedBy, + object: systemRecordRecordSubjectV1(NETWORK, STABLE_KEY), + }), + ])); + }); + + it('fails closed when any persisted object belongs to a different materialization', () => { + const rootClaimSet = { + objectType: 'system-record-root-claim-set', kind: 'agents', networkId: NETWORK, + stableKeyHash: STABLE_KEY, currentRoot: ROOT, historicalRoots: [], + } as const; + const appliedState = activeState(computeSystemRecordRootClaimSetDigestV1(rootClaimSet)); + expect(() => buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: '0', + ownedSubjectTable: TABLE, + rootClaimSet, + capacityState: { + objectType: 'system-record-capacity-state', kind: 'agents', networkId: NETWORK, + revision: '1', liveRecordCount: '1', stateBytes: '65536', tableBytes: '80', + projectionBytes: '4096', projectionQuads: '3', + }, + receipt: { + objectType: 'system-record-materialization-receipt', kind: 'agents', networkId: NETWORK, + stableKeyHash: STABLE_KEY, stateRevision: '1', + appliedStateDigest: `0x${'cc'.repeat(32)}`, + headDigest: HEAD, materializationEpoch: '2', + }, + })).toThrow(/receipt does not bind/); + + const accessor = Object.defineProperty({}, 'appliedState', { + enumerable: true, + get: () => appliedState, + }); + for (const [key, value] of Object.entries({ + headVersion: '0', + ownedSubjectTable: TABLE, + rootClaimSet, + capacityState: { + objectType: 'system-record-capacity-state', kind: 'agents', networkId: NETWORK, + revision: '1', liveRecordCount: '1', stateBytes: '65536', tableBytes: '80', + projectionBytes: '4096', projectionQuads: '3', + }, + receipt: { + objectType: 'system-record-materialization-receipt', kind: 'agents', networkId: NETWORK, + stableKeyHash: STABLE_KEY, stateRevision: '1', + appliedStateDigest: computeSystemRecordAppliedStateDigestV1(appliedState), + headDigest: HEAD, materializationEpoch: '2', + }, + })) Object.defineProperty(accessor, key, { enumerable: true, value }); + expect(() => buildSystemRecordReservedStateQuadsV1(accessor as never)).toThrow(/data properties/); + expect(() => buildSystemRecordReservedStateQuadsV1(new Proxy(accessor, {}) as never)) + .toThrow(/plain data/); + }); +}); + +function activeState(rootClaimSetDigest: `0x${string}`): SystemRecordAppliedStatePresentV1 { + const tableBytes = new TextEncoder().encode(JSON.stringify(TABLE)).byteLength; + return { + objectType: 'system-record-applied-state', state: 'present', kind: 'agents', + networkId: NETWORK, stableKeyHash: STABLE_KEY, peerId: PEER, + stateRevision: '1', status: 'active', headDigest: HEAD, + transitionLineage: [], projectionDigest: PROJECTION, + projectionBytes: '4096', projectionQuads: '3', + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1(ROOT, TABLE), + ownedSubjectCount: '1', ownedSubjectTableBytes: tableBytes.toString(), + currentRoot: ROOT, historicalRoots: [], conflictDigestSlots: [], conflictOverflow: false, + materializationEpoch: '2', rootClaimSetDigest, + accountedBytes: computeSystemRecordAccountedBytesV1(tableBytes, 4096).toString(), + }; +} diff --git a/packages/storage/test/system-record-state-snapshot-v1.test.ts b/packages/storage/test/system-record-state-snapshot-v1.test.ts new file mode 100644 index 0000000000..ffc02a3fbd --- /dev/null +++ b/packages/storage/test/system-record-state-snapshot-v1.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from 'vitest'; + +import { + canonicalizeOwnedSubjectTableObjectV1, + computeOwnedSubjectTableDigestV1, + computeSystemRecordAccountedBytesV1, + computeSystemRecordAppliedStateDigestV1, + computeSystemRecordRootClaimSetDigestV1, + computeSystemRecordStableKeyHashV1, + SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES, + type OwnedSubjectTableObjectV1, + type SystemRecordAppliedStatePresentV1, + type SystemRecordCapacityStateV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { + buildSystemRecordReservedStateQuadsV1, + systemRecordRootClaimSubjectV1, + SYSTEM_RECORD_V1_PREDICATES, +} from '../src/system-record-rdf-schema-v1-internal.js'; +import { + assertAuthenticSystemRecordAppliedSnapshotV1, + assertSystemRecordRootClaimSnapshotV1, + decodeSystemRecordAppliedSnapshotV1, +} from '../src/system-record-state-snapshot-v1-internal.js'; + +const NETWORK = 'otp:20430' as const; +const OTHER_NETWORK = 'base:84532' as const; +const PEER = '12D3KooWJ1TsijH7H5F74hfAD5XishQz3sxrmAtVY37GtNd9CqYf'; +const ROOT = 'did:dkg:agent:0x1111111111111111111111111111111111111111'; +const OTHER_ROOT = 'did:dkg:agent:0x2222222222222222222222222222222222222222'; +const HEAD = `0x${'aa'.repeat(32)}` as const; +const PROJECTION = `0x${'bb'.repeat(32)}` as const; +const STABLE_KEY = computeSystemRecordStableKeyHashV1(NETWORK, PEER); +const TABLE = Object.freeze([ROOT]) as OwnedSubjectTableObjectV1; + +describe('system-record reserved-state snapshot decoder', () => { + it('decodes canonical absent state with either absent or persisted global capacity', () => { + const canonical = tuple(); + const initial = decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: canonical.epoch, + }); + expect(initial).toMatchObject({ + state: 'absent', + capacityState: { revision: '0', liveRecordCount: '0' }, + }); + expect(initial.requiredAbsentReservedSubjects).toHaveLength(3); + expect(initial.requiredAbsentReservedSubjects).toEqual( + [...initial.requiredAbsentReservedSubjects].sort(), + ); + + const occupied = decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [...canonical.capacity, ...canonical.epoch], + }); + expect(occupied).toMatchObject({ + state: 'absent', + capacityState: { revision: '7', liveRecordCount: '3' }, + }); + expect(occupied.requiredAbsentReservedSubjects).toHaveLength(2); + expect(occupied.previousReservedQuads).toHaveLength(3); + expect(Object.isFrozen(occupied.previousReservedQuads)).toBe(true); + }); + + it('decodes the one exact present tuple and retains the exact root-claim expectation', () => { + const canonical = tuple(); + const decoded = decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [...canonical.receipt, ...canonical.record, ...canonical.epoch, ...canonical.capacity], + }); + expect(decoded).toMatchObject({ + state: 'present', + appliedState: { stableKeyHash: STABLE_KEY, stateRevision: '4' }, + capacityState: { revision: '7' }, + materializationEpoch: '2', + }); + if (decoded.state !== 'present') throw new Error('expected present state'); + expect(decoded.expectedRootClaimQuads).toEqual(canonical.rootClaims); + expect(decoded.previousReservedQuads).toHaveLength(12); + expect(Object.isFrozen(decoded)).toBe(true); + expect(() => assertAuthenticSystemRecordAppliedSnapshotV1(decoded)).not.toThrow(); + expect(() => assertAuthenticSystemRecordAppliedSnapshotV1({ ...decoded })).toThrow(/exact decoder/); + }); + + it('rejects missing, extra, duplicate, malformed, and mismatched-epoch rows', () => { + const canonical = tuple(); + const all = [...canonical.record, ...canonical.capacity, ...canonical.epoch, ...canonical.receipt]; + const decode = (quads: typeof all, epoch = '2') => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: epoch, + quads, + }); + + expect(() => decode(all.filter((quad) => quad !== canonical.receipt[0]))).toThrow(/receipt/); + expect(() => decode([...all, { ...canonical.record[0] }])).toThrow(/duplicate/); + let accessorCalls = 0; + const accessorQuad = Object.defineProperty({ + predicate: canonical.record[0].predicate, + object: canonical.record[0].object, + graph: canonical.record[0].graph, + }, 'subject', { + enumerable: true, + get: () => { + accessorCalls += 1; + return canonical.record[0].subject; + }, + }); + expect(() => decode([...all.slice(1), accessorQuad] as typeof all)).toThrow(/data properties/); + expect(accessorCalls).toBe(0); + expect(() => decode([...all.slice(1), new Proxy(canonical.record[0], {})] as typeof all)) + .toThrow(/non-data quad/); + let arrayAccessorCalls = 0; + const accessorRows = [...all]; + Object.defineProperty(accessorRows, '0', { + enumerable: true, + get: () => { + arrayAccessorCalls += 1; + return all[0]; + }, + }); + expect(() => decode(accessorRows)).toThrow(/dense data array/); + expect(arrayAccessorCalls).toBe(0); + const sparseRows = [...all]; + delete sparseRows[0]; + expect(() => decode(sparseRows)).toThrow(/dense data array/); + expect(() => decode([...all, { + ...canonical.record[0], + predicate: canonical.capacity[0].predicate, + object: canonical.capacity[0].object, + }])).toThrow(/fixed canonical RDF schema/); + expect(() => decode(all.map((quad) => quad === canonical.record[0] + ? { ...quad, object: '"not-json"^^' } + : quad))).toThrow(); + expect(() => decode(all, '3')).toThrow(/epoch changed/); + expect(() => decode([...canonical.record, ...canonical.epoch, ...canonical.receipt])) + .toThrow(/requires the global capacity/); + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [canonical.capacity[0], ...canonical.epoch], + })).toThrow(/capacity digest/); + expect(() => decodeTuple(tuple({ + capacityState: { + objectType: 'system-record-capacity-state', + kind: 'agents', + networkId: NETWORK, + revision: '7', + liveRecordCount: '0', + stateBytes: '0', + tableBytes: '0', + projectionBytes: '0', + projectionQuads: '0', + }, + }))).toThrow(/does not account/); + expect(() => decodeTuple(tuple({ + capacityState: { + objectType: 'system-record-capacity-state', + kind: 'agents', + networkId: NETWORK, + revision: '7', + liveRecordCount: '3', + stateBytes: '1024', + tableBytes: '80', + projectionBytes: '8192', + projectionQuads: '6', + }, + }))).toThrow(/fixed per-record precharge/); + + let outerAccessorCalls = 0; + const accessorInput = Object.defineProperty({ + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: all, + }, 'networkId', { + enumerable: true, + get: () => { + outerAccessorCalls += 1; + return NETWORK; + }, + }); + expect(() => decodeSystemRecordAppliedSnapshotV1(accessorInput as never)) + .toThrow(/data properties/); + expect(outerAccessorCalls).toBe(0); + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: all, + extra: true, + } as never)).toThrow(/unknown or missing/); + expect(() => decodeSystemRecordAppliedSnapshotV1(new Proxy({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: all, + }, {}) as never)).toThrow(/plain data object/); + }); + + it('enforces the decoded reserved-read byte cap at exactly one byte over', () => { + const epoch = tuple().epoch[0]; + const paddingBase = { + subject: epoch.subject, + predicate: SYSTEM_RECORD_V1_PREDICATES.appliedState, + graph: epoch.graph, + }; + const fixedBytes = quadDecodedBytes(epoch) + + Buffer.byteLength(paddingBase.subject, 'utf8') + + Buffer.byteLength(paddingBase.predicate, 'utf8') + + Buffer.byteLength(paddingBase.graph, 'utf8') + + 2; + const atLimit = SYSTEM_RECORD_MAX_ATOMIC_RESERVED_INSPECTION_RESPONSE_BYTES - fixedBytes; + const decodeWithPadding = (length: number) => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [epoch, { ...paddingBase, object: `"${'x'.repeat(length)}"` }], + }); + + expect(() => decodeWithPadding(atLimit)).toThrow(/fixed canonical RDF schema/); + expect(() => decodeWithPadding(atLimit + 1)).toThrow(/decoded byte bound/); + }); + + it('rejects cross-network/key objects and table count or byte disagreement', () => { + const canonical = tuple(); + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: OTHER_NETWORK, + stableKeyHash: computeSystemRecordStableKeyHashV1(OTHER_NETWORK, PEER), + materializationEpoch: '2', + quads: [...canonical.record, ...canonical.capacity, ...canonical.epoch, ...canonical.receipt], + })).toThrow(/out-of-scope/); + expect(() => decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: `0x${'cc'.repeat(32)}`, + materializationEpoch: '2', + quads: [...canonical.record, ...canonical.capacity, ...canonical.epoch, ...canonical.receipt], + })).toThrow(/out-of-scope/); + + const wrongCount = state({ ownedSubjectCount: '2' }); + expect(() => decodeTuple(tuple({ appliedState: wrongCount }))).toThrow(/count or byte/); + + const actualTableBytes = canonicalizeOwnedSubjectTableObjectV1(ROOT, TABLE).byteLength; + const wrongByteCount = actualTableBytes + 1; + const wrongBytes = state({ + ownedSubjectTableBytes: wrongByteCount.toString(), + accountedBytes: computeSystemRecordAccountedBytesV1(wrongByteCount, 4096).toString(), + }); + expect(() => decodeTuple(tuple({ appliedState: wrongBytes }))).toThrow(/count or byte/); + }); + + it('validates exact root claims and proves candidate roots absent in the same bounded read', () => { + const canonical = tuple(); + const absentRoot = systemRecordRootClaimSubjectV1(NETWORK, OTHER_ROOT); + expect(assertSystemRecordRootClaimSnapshotV1( + canonical.rootClaims, + canonical.rootClaims, + [absentRoot], + )).toEqual(expect.arrayContaining(canonical.rootClaims)); + expect(() => assertSystemRecordRootClaimSnapshotV1( + canonical.rootClaims.slice(1), + canonical.rootClaims, + [absentRoot], + )).toThrow(/root-claim state/); + expect(() => assertSystemRecordRootClaimSnapshotV1( + [...canonical.rootClaims, { ...canonical.rootClaims[0] }], + canonical.rootClaims, + [absentRoot], + )).toThrow(/duplicate/); + expect(() => assertSystemRecordRootClaimSnapshotV1( + [...canonical.rootClaims, { ...canonical.rootClaims[0], subject: absentRoot }], + canonical.rootClaims, + [absentRoot], + )).toThrow(/root-claim state/); + expect(() => assertSystemRecordRootClaimSnapshotV1( + canonical.rootClaims, + canonical.rootClaims, + [canonical.rootClaims[0].subject], + )).toThrow(/present and absent/); + + const crossNetwork = systemRecordRootClaimSubjectV1(OTHER_NETWORK, OTHER_ROOT); + expect(() => assertSystemRecordRootClaimSnapshotV1( + [...canonical.rootClaims, { ...canonical.rootClaims[0], subject: crossNetwork }], + canonical.rootClaims, + [absentRoot], + )).toThrow(/out-of-scope/); + }); +}); + +function decodeTuple(value: ReturnType) { + return decodeSystemRecordAppliedSnapshotV1({ + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + materializationEpoch: '2', + quads: [...value.record, ...value.capacity, ...value.epoch, ...value.receipt], + }); +} + +function tuple(overrides: { + readonly appliedState?: SystemRecordAppliedStatePresentV1; + readonly rootClaimSet?: Parameters[0]['rootClaimSet']; + readonly capacityState?: SystemRecordCapacityStateV1; +} = {}) { + const rootClaimSet = overrides.rootClaimSet ?? { + objectType: 'system-record-root-claim-set', + kind: 'agents', + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + currentRoot: ROOT, + historicalRoots: [], + } as const; + const appliedState = overrides.appliedState ?? state({ + rootClaimSetDigest: computeSystemRecordRootClaimSetDigestV1(rootClaimSet), + }); + const appliedStateDigest = computeSystemRecordAppliedStateDigestV1(appliedState); + return buildSystemRecordReservedStateQuadsV1({ + appliedState, + headVersion: '0', + ownedSubjectTable: TABLE, + rootClaimSet, + capacityState: overrides.capacityState ?? { + objectType: 'system-record-capacity-state', + kind: 'agents', + networkId: NETWORK, + revision: '7', + liveRecordCount: '3', + stateBytes: (3 * 65_536).toString(), + tableBytes: '80', + 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, + }, + }); +} + +function state( + overrides: Partial = {}, +): SystemRecordAppliedStatePresentV1 { + const tableBytes = canonicalizeOwnedSubjectTableObjectV1(ROOT, TABLE).byteLength; + const claims = { + objectType: 'system-record-root-claim-set', + kind: 'agents', + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + currentRoot: ROOT, + historicalRoots: [], + } as const; + return { + objectType: 'system-record-applied-state', + state: 'present', + kind: 'agents', + networkId: NETWORK, + stableKeyHash: STABLE_KEY, + peerId: PEER, + stateRevision: '4', + status: 'active', + headDigest: HEAD, + transitionLineage: [], + projectionDigest: PROJECTION, + projectionBytes: '4096', + projectionQuads: '3', + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1(ROOT, TABLE), + ownedSubjectCount: '1', + ownedSubjectTableBytes: tableBytes.toString(), + currentRoot: ROOT, + historicalRoots: [], + conflictDigestSlots: [], + conflictOverflow: false, + materializationEpoch: '2', + rootClaimSetDigest: computeSystemRecordRootClaimSetDigestV1(claims), + accountedBytes: computeSystemRecordAccountedBytesV1(tableBytes, 4096).toString(), + ...overrides, + } as SystemRecordAppliedStatePresentV1; +} + +function quadDecodedBytes( + quad: Readonly<{ subject: string; predicate: string; object: string; graph: string }>, +): number { + return Buffer.byteLength(quad.subject, 'utf8') + + Buffer.byteLength(quad.predicate, 'utf8') + + Buffer.byteLength(quad.object, 'utf8') + + Buffer.byteLength(quad.graph, 'utf8'); +} diff --git a/packages/storage/test/system-record-utf8-order-v1.test.ts b/packages/storage/test/system-record-utf8-order-v1.test.ts new file mode 100644 index 0000000000..133ddabaa9 --- /dev/null +++ b/packages/storage/test/system-record-utf8-order-v1.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { compareSystemRecordUtf8V1 } from '../src/system-record-utf8-order-v1-internal.js'; + +describe('system-record canonical UTF-8 ordering', () => { + it('matches encoded byte ordering without comparator allocations', () => { + const values = [ + '', 'a', 'aa', 'z', '\u007f', '\u0080', '\u07ff', '\u0800', + '\ud7ff', '\ue000', '\uffff', '\ud800\udc00', '\udbff\udfff', + ]; + const expected = [...values].sort((left, right) => + Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'))); + expect([...values].sort(compareSystemRecordUtf8V1)).toEqual(expected); + }); +}); diff --git a/packages/storage/test/system-record-verified-replacement-v1.test.ts b/packages/storage/test/system-record-verified-replacement-v1.test.ts new file mode 100644 index 0000000000..b691c368cf --- /dev/null +++ b/packages/storage/test/system-record-verified-replacement-v1.test.ts @@ -0,0 +1,640 @@ +import { readFileSync } from 'node:fs'; + +import { + computeKaBundleProjectionDigestV1, + encodeWorkspaceEncryptionKey, + keccak256, + SENTINEL_NO_PRIVATE_V10, + tripleContentV10, + V10MerkleTree, +} from '@origintrail-official/dkg-core'; +import { + buildAgentProfileVerificationClosureV1, + canonicalizeSignedSystemRecordEnvelopeV1, + computeAgentProfileHeadObjectDigestV1, + computeOwnedSubjectTableDigestV1, + digestSystemRecordBytesV1, + SYSTEM_RECORD_DIGEST_DOMAINS_V1, + type AgentProfileActiveHeadObjectV1, + type AgentProfileVerifiedAuthoritySummaryV1, + type NetworkIdV1, + type SignedAgentProfileHeadEnvelopeV1, +} from '@origintrail-official/dkg-core/system-record-v1'; +import { describe, expect, it } from 'vitest'; + +import { + createSystemRecordVerifiedReplacementRegistryV1, + type SystemRecordActiveReplacementIssueV1, + type SystemRecordVerifiedReplacementLaneBindingV1, +} from '../src/system-record-verified-replacement-v1-internal.js'; + +interface Vectors { + readonly variants: { + readonly active: { readonly object: AgentProfileActiveHeadObjectV1 }; + }; + readonly signed: { + readonly activeEip191: { readonly envelope: SignedAgentProfileHeadEnvelopeV1 }; + }; +} + +const vectors = JSON.parse(readFileSync(new URL( + '../../core/test/fixtures/system-record-v1/vectors.json', + import.meta.url, +), 'utf8')) as Vectors; + +function projectionFor(root: string) { + return [ + { + subject: root, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'https://dkg.network/ontology#Agent', + graph: '', + }, + { subject: root, predicate: 'https://schema.org/description', object: '"b"', graph: '' }, + { subject: root, predicate: 'https://schema.org/name', object: '"a"', graph: '' }, + ] as const; +} + +function projectionBytes(root: string): Uint8Array { + return canonicalBytesFor(projectionFor(root)); +} + +function canonicalBytesFor(quads: readonly Readonly<{ + subject: string; predicate: string; object: string; +}>[]): Uint8Array { + return new TextEncoder().encode( + quads.map((quad) => ( + `${new TextDecoder().decode(tripleContentV10(quad.subject, quad.predicate, quad.object))}\n` + )).join(''), + ); +} + +function projectionContentDigest(root: string): `0x${string}` { + return contentDigestFor(projectionFor(root)); +} + +function contentDigestFor(quads: readonly Readonly<{ + subject: string; predicate: string; object: string; +}>[]): `0x${string}` { + const leaves = quads.map((quad) => keccak256( + tripleContentV10(quad.subject, quad.predicate, quad.object), + )); + const contentRoot = V10MerkleTree.computeKARoot( + new V10MerkleTree(leaves).root, + SENTINEL_NO_PRIVATE_V10, + ); + return `0x${Buffer.from(contentRoot).toString('hex')}`; +} + +async function mintAuthority( + head: AgentProfileActiveHeadObjectV1, +): Promise { + const bundle = new TextEncoder().encode('verified-profile-bundle'); + const envelope = { + ...structuredClone(vectors.signed.activeEip191.envelope), + object: head, + objectDigest: computeAgentProfileHeadObjectDigestV1(head), + } as SignedAgentProfileHeadEnvelopeV1; + const headBytes = canonicalizeSignedSystemRecordEnvelopeV1(envelope); + const artifacts = new Map([ + [`agent-profile-head:${envelope.objectDigest}`, { + objectKind: 'agent-profile-head' as const, + digest: envelope.objectDigest, + canonicalBytes: headBytes, + }], + [`profile-bundle:${head.bundleDigest}`, { + objectKind: 'profile-bundle' as const, + digest: head.bundleDigest, + canonicalBytes: bundle, + }], + ]); + const closure = await buildAgentProfileVerificationClosureV1(envelope.objectDigest, { + nowMs: Date.parse('2026-08-05T12:10:00Z'), + resolve: async (reference) => artifacts.get(`${reference.objectKind}:${reference.digest}`), + verifyAuthorityEnvelope: () => true, + verifyCurrentBundle: (_head, bytes) => Buffer.from(bytes).equals(Buffer.from(bundle)), + }); + return closure.authoritySummary; +} + +const VERIFIED = await (async () => { + const source = structuredClone(vectors.variants.active.object); + const canonicalProjectionBytes = projectionBytes(source.rootSubject); + const contentDigest = projectionContentDigest(source.rootSubject); + const head = { + ...source, + projectionBytes: String(canonicalProjectionBytes.byteLength), + contentDigest, + graphScopedAuthorSeal: { + ...source.graphScopedAuthorSeal, + assertionMerkleRoot: contentDigest, + }, + bundleDigest: digestSystemRecordBytesV1( + SYSTEM_RECORD_DIGEST_DOMAINS_V1.profileBundle, + new TextEncoder().encode('verified-profile-bundle'), + ), + } as AgentProfileActiveHeadObjectV1; + return Object.freeze({ + head, + authority: await mintAuthority(head), + canonicalProjectionBytes, + }); +})(); + +function fixture(): { + readonly input: SystemRecordActiveReplacementIssueV1; + readonly bindings: SystemRecordVerifiedReplacementLaneBindingV1; +} { + const head = structuredClone(VERIFIED.head); + const sessionIdentity = Object.freeze(Object.create(null) as object); + const bindings = { + networkId: head.networkId as NetworkIdV1, + kind: 'agents' as const, + mode: 'shadow' as const, + sessionIdentity, + activationGeneration: '7', + childGeneration: '11', + materializationEpoch: '13', + }; + return { + bindings, + input: { + ...bindings, + admittedDeadlineMs: 42_000, + head, + verifiedAuthoritySummary: VERIFIED.authority, + canonicalProjectionBytes: new Uint8Array(VERIFIED.canonicalProjectionBytes), + ownedSubjectTable: [head.rootSubject], + projectionQuads: projectionFor(head.rootSubject), + }, + }; +} + +async function replacementFor( + projectionQuads: readonly Readonly<{ + subject: string; predicate: string; object: string; graph: string; + }>[], + ownedSubjectTable: readonly string[], +): Promise { + const base = fixture().input; + const canonicalProjectionBytes = canonicalBytesFor(projectionQuads); + const contentDigest = contentDigestFor(projectionQuads); + const head = { + ...base.head, + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1( + base.head.rootSubject, + ownedSubjectTable, + ), + ownedSubjectCount: String(ownedSubjectTable.length), + projectionBytes: String(canonicalProjectionBytes.byteLength), + projectionQuads: String(projectionQuads.length), + contentDigest, + graphScopedAuthorSeal: { + ...base.head.graphScopedAuthorSeal, + assertionMerkleRoot: contentDigest, + publicTripleCount: String(projectionQuads.length), + }, + } as AgentProfileActiveHeadObjectV1; + return { + ...base, + head, + verifiedAuthoritySummary: await mintAuthority(head), + canonicalProjectionBytes, + projectionQuads, + ownedSubjectTable, + }; +} + +describe('system-record verified replacement V1', () => { + it('issues an empty frozen handle and returns one deep-owned immutable snapshot', () => { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const sourceQuads = input.projectionQuads as Array<{ object: string }>; + const sourceSubjects = input.ownedSubjectTable as string[]; + const sourceBytes = input.canonicalProjectionBytes; + const expectedProjectionDigest = computeKaBundleProjectionDigestV1(sourceBytes); + const handle = registry.issuer.issueActive(input); + + expect(Object.isFrozen(handle)).toBe(true); + expect(Object.getPrototypeOf(handle)).toBeNull(); + expect(Reflect.ownKeys(handle)).toEqual([]); + expect(JSON.stringify(handle)).toBe('{}'); + + sourceQuads[0].object = '"mutated"'; + sourceSubjects[0] = 'urn:test:unowned'; + sourceBytes.fill(0); + const facts = registry.consumer.consume(handle, bindings); + expect(facts.projectionQuads[0].object).toBe('https://dkg.network/ontology#Agent'); + expect(facts.ownedSubjectTable).toEqual([facts.head.rootSubject]); + // The authority capability is deliberately retained by identity: core is + // the only component that can mint it, and the mint freezes both the + // capability and every nested lineage/root collection. Copying it here + // would destroy the opaque authority rather than improve ownership. + expect(facts.verifiedAuthoritySummary).toBe(input.verifiedAuthoritySummary); + expect(Object.isFrozen(facts.verifiedAuthoritySummary)).toBe(true); + expect(Object.isFrozen(facts.verifiedAuthoritySummary.transitionLineage)).toBe(true); + expect(facts.verifiedAuthoritySummary.transitionLineage.every(Object.isFrozen)).toBe(true); + expect(Object.isFrozen(facts.verifiedAuthoritySummary.historicalRoots)).toBe(true); + expect(Reflect.set( + facts.verifiedAuthoritySummary, + 'candidateHeadDigest', + `0x${'00'.repeat(32)}`, + )).toBe(false); + expect(facts.projectionDigest).toBe(expectedProjectionDigest); + expect(Object.isFrozen(facts)).toBe(true); + expect(Object.isFrozen(facts.head)).toBe(true); + expect(Object.isFrozen(facts.ownedSubjectTable)).toBe(true); + expect(Object.isFrozen(facts.projectionQuads)).toBe(true); + expect(facts.projectionQuads.every((quad) => Object.isFrozen(quad))).toBe(true); + expect(facts.projectionQuads.every((quad) => quad.graph === '')).toBe(true); + expect('sessionIdentity' in facts).toBe(false); + expect(facts.admittedDeadlineMs).toBe(input.admittedDeadlineMs); + expect(Object.isFrozen(facts.reservationIdentity)).toBe(true); + expect(Object.getPrototypeOf(facts.reservationIdentity)).toBeNull(); + expect(Reflect.ownKeys(facts.reservationIdentity)).toEqual([]); + }); + + it('rejects forged, copied, serialized, prototype-bearing, and cross-registry handles', () => { + const first = createSystemRecordVerifiedReplacementRegistryV1(); + const second = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const handle = first.issuer.issueActive(input); + + for (const forged of [ + {}, + Object.create(null) as object, + Object.freeze(Object.create(null) as object), + { ...(handle as object) }, + JSON.parse(JSON.stringify(handle)) as object, + structuredClone(handle as object), + Object.create({ brand: 'system-record-verified-replacement-v1' }) as object, + null, + undefined, + 'system-record-verified-replacement-v1', + ]) { + expect(() => first.consumer.consume(forged, bindings)).toThrow(/handle/); + } + expect(() => second.consumer.consume(handle, bindings)).toThrow(/another registry/); + expect(first.consumer.consume(handle, bindings).head.state).toBe('active'); + }); + + it('is one-shot and consumes before returning facts', () => { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const handle = registry.issuer.issueActive(input); + expect(registry.consumer.consume(handle, bindings).head.peerId).toBe(input.head.peerId); + expect(() => registry.consumer.consume(handle, bindings)).toThrow(/already consumed/); + }); + + it('owns one nonqueued atomic reservation and releases handle or facts exactly once', () => { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const first = registry.issuer.issueActive(input); + expect(() => registry.issuer.issueActive(input)).toThrow(/reservation is already live/); + + registry.consumer.release(first); + expect(() => registry.consumer.release(first)).toThrow(/already released/); + const second = registry.issuer.issueActive(input); + const facts = registry.consumer.consume(second, bindings); + registry.consumer.release(facts); + expect(() => registry.consumer.release(facts)).toThrow(/already released/); + expect(registry.issuer.issueActive(input)).toBeDefined(); + }); + + it('discards only a live unconsumed proof and refuses aliases after consumption', () => { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const discarded = registry.issuer.issueActive(input); + registry.consumer.discardProof(discarded); + expect(() => registry.consumer.discardProof(discarded)).toThrow(/live and unconsumed/); + + const consumed = registry.issuer.issueActive(input); + const facts = registry.consumer.consume(consumed, bindings); + expect(() => registry.consumer.discardProof(consumed)).toThrow(/live and unconsumed/); + expect(() => registry.consumer.discardProof(facts)).toThrow(/handle/); + expect(() => registry.issuer.issueActive(input)).toThrow(/reservation is already live/); + + registry.consumer.release(facts); + expect(registry.issuer.issueActive(input)).toBeDefined(); + }); + + it('rejects weighted retained buffers beyond the 12-MiB lease before reuse', () => { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const facts = registry.consumer.consume(registry.issuer.issueActive(input), bindings); + expect(() => registry.consumer.replaceCharge( + facts, + 'prepared', + 12 * 1024 * 1024, + )).toThrow(/lease capacity exceeded/); + registry.consumer.replaceCharge(facts, 'prepared', 8 * 1024 * 1024); + registry.consumer.replaceCharge(facts, 'response', 3 * 1024 * 1024); + expect(() => registry.consumer.replaceCharge( + facts, + 'request', + 2 * 1024 * 1024, + )).toThrow(/lease capacity exceeded/); + registry.consumer.release(facts); + expect(registry.issuer.issueActive(input)).toBeDefined(); + }); + + it('retains an exact recovery transfer until fulfillment or rejection settles', async () => { + for (const rejects of [false, true]) { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const facts = registry.consumer.consume(registry.issuer.issueActive(input), bindings); + const ownership = Object.freeze(Object.create(null) as object); + let settle!: () => void; + const completion = new Promise((resolve, reject) => { + settle = () => rejects ? reject(new Error('terminal unavailable')) : resolve(); + }); + registry.consumer.transferToRecovery(facts, ownership, completion); + expect(() => registry.consumer.release(facts)).toThrow(/belongs to recovery/); + expect(() => registry.issuer.issueActive(input)).toThrow(/reservation is already live/); + settle(); + await completion.catch(() => undefined); + await Promise.resolve(); + expect(registry.issuer.issueActive(input)).toBeDefined(); + } + }); + + it('rejects every cross-binding substitution without burning the valid handle', () => { + const variants: Array<[keyof SystemRecordVerifiedReplacementLaneBindingV1, unknown]> = [ + ['networkId', 'otp:999'], + ['kind', 'not-agents'], + ['mode', 'authoritative'], + ['sessionIdentity', Object.freeze(Object.create(null) as object)], + ['activationGeneration', '8'], + ['childGeneration', '12'], + ['materializationEpoch', '14'], + ]; + for (const [key, value] of variants) { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + const handle = registry.issuer.issueActive(input); + expect(() => registry.consumer.consume(handle, { ...bindings, [key]: value })) + .toThrow(/lifecycle binding|kind|mode/); + expect(registry.consumer.consume(handle, bindings).head.state).toBe('active'); + } + }); + + it('rejects caller graph scope, CAS fields, accessors, sparse arrays, and invalid RDF', () => { + const valid = fixture(); + const attempts: unknown[] = [ + { ...valid.input, graphUri: 'urn:caller:graph' }, + { ...valid.input, stateRevision: '7' }, + { + ...valid.input, + canonicalProjectionBytes: Uint8Array.from( + valid.input.canonicalProjectionBytes, + (byte, index) => index === 0 ? byte ^ 1 : byte, + ), + }, + (() => { + const projectionQuads = valid.input.projectionQuads.map((quad, index) => ( + index === 2 ? { ...quad, object: '"c"' } : quad + )); + return { + ...valid.input, + projectionQuads, + canonicalProjectionBytes: canonicalBytesFor(projectionQuads), + }; + })(), + { + ...valid.input, + projectionQuads: valid.input.projectionQuads.map((quad, index) => ( + index === 0 ? { ...quad, graph: 'urn:caller:graph' } : quad + )), + }, + { + ...valid.input, + projectionQuads: [ + { ...valid.input.projectionQuads[0], subject: '_:blank' }, + ...valid.input.projectionQuads.slice(1), + ], + }, + { + ...valid.input, + projectionQuads: valid.input.projectionQuads.map((quad, index) => ( + index === 2 ? { ...quad, object: `"${'x'.repeat(10_000)}"` } : quad + )), + }, + { + ...valid.input, + projectionQuads: valid.input.projectionQuads.map((quad, index) => ( + index === 2 ? { ...quad, object: `"${'\u{1f642}'.repeat(10_000)}"` } : quad + )), + }, + { + ...valid.input, + projectionQuads: [ + { ...valid.input.projectionQuads[0], object: '" } DROP ALL #' }, + ...valid.input.projectionQuads.slice(1), + ], + }, + { + ...valid.input, + projectionQuads: [ + valid.input.projectionQuads[0], + valid.input.projectionQuads[0], + valid.input.projectionQuads[2], + ], + }, + ]; + const withAccessor = { ...valid.input } as Record; + Object.defineProperty(withAccessor, 'mode', { enumerable: true, get: () => 'shadow' }); + attempts.push(withAccessor); + const sparse = new Array(3); + sparse[0] = valid.input.projectionQuads[0]; + sparse[2] = valid.input.projectionQuads[2]; + attempts.push({ ...valid.input, projectionQuads: sparse }); + + for (const candidate of attempts) { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + expect(() => registry.issuer.issueActive(candidate as SystemRecordActiveReplacementIssueV1)) + .toThrow(); + } + }); + + it('rejects tombstones, head/table/count mismatches, and unowned projection subjects', () => { + const { input } = fixture(); + const attempts = [ + { ...input, head: { ...input.head, state: 'tombstone' } }, + { ...input, ownedSubjectTable: [] }, + { + ...input, + projectionQuads: input.projectionQuads.map((quad, index) => ( + index === 0 ? { ...quad, subject: 'urn:test:other' } : quad + )), + }, + { + ...input, + verifiedAuthoritySummary: Object.freeze({ + candidateHeadDigest: `0x${'00'.repeat(32)}`, + transitionLineage: Object.freeze([]), + historicalRoots: Object.freeze([]), + }), + }, + ]; + for (const candidate of attempts) { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + expect(() => registry.issuer.issueActive(candidate as SystemRecordActiveReplacementIssueV1)) + .toThrow(); + } + }); + + it('accepts protocol byte ordering for a linked derived subject and canonical literal', async () => { + const base = fixture(); + const root = base.input.head.rootSubject; + const capability = `${root}/.well-known/genid/cap1`; + // Full encoded line ordering puts '/' before the root term's closing '>'. + const projectionQuads = [ + { + subject: capability, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'https://eips.ethereum.org/erc-8004#Capability', + graph: '', + }, + { + subject: root, + predicate: 'https://eips.ethereum.org/erc-8004#capabilities', + object: capability, + graph: '', + }, + { + subject: root, + predicate: 'https://schema.org/name', + object: '"Meow"@en', + graph: '', + }, + ] as const; + const canonicalProjectionBytes = canonicalBytesFor(projectionQuads); + const contentDigest = contentDigestFor(projectionQuads); + const ownedSubjectTable = [root, capability] as const; + const head = { + ...base.input.head, + ownedSubjectTableDigest: computeOwnedSubjectTableDigestV1(root, ownedSubjectTable), + ownedSubjectCount: '2', + projectionBytes: String(canonicalProjectionBytes.byteLength), + projectionQuads: '3', + contentDigest, + graphScopedAuthorSeal: { + ...base.input.head.graphScopedAuthorSeal, + assertionMerkleRoot: contentDigest, + publicTripleCount: '3', + }, + } as AgentProfileActiveHeadObjectV1; + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const handle = registry.issuer.issueActive({ + ...base.input, + head, + verifiedAuthoritySummary: await mintAuthority(head), + canonicalProjectionBytes, + projectionQuads, + ownedSubjectTable, + }); + const facts = registry.consumer.consume(handle, base.bindings); + expect(facts.projectionQuads.map((quad) => quad.subject)).toEqual([ + capability, + root, + root, + ]); + }); + + it('rejects unlinked, wrong-kind-linked, and underived encryption subjects', async () => { + const root = fixture().input.head.rootSubject; + const capability = `${root}/.well-known/genid/cap1`; + const underivedKey = `${root}#x25519-${'0'.repeat(32)}`; + const publicKey = encodeWorkspaceEncryptionKey(new Uint8Array(32).fill(9)); + const candidates = [ + await replacementFor([ + { + subject: capability, + predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', + object: 'https://eips.ethereum.org/erc-8004#Capability', + graph: '', + }, + { subject: root, predicate: 'https://schema.org/name', object: '"root"', graph: '' }, + ], [root, capability]), + await replacementFor([ + { + subject: root, + predicate: 'https://eips.ethereum.org/erc-8004#capabilities', + object: `${root}/.well-known/genid/offering1`, + graph: '', + }, + ], [root]), + await replacementFor([ + { + subject: underivedKey, + predicate: 'https://dkg.network/ontology#revokedAt', + object: '"2026-08-05T12:00:00Z"', + graph: '', + }, + { + subject: root, + predicate: 'https://dkg.network/ontology#publicEncryptionKey', + object: `"${publicKey}"`, + graph: '', + }, + ], [root, underivedKey]), + ]; + for (const candidate of candidates) { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + expect(() => registry.issuer.issueActive(candidate)).toThrow(/linked|link|derived/); + } + }); + + it('rejects proxies before invoking their traps', () => { + const registry = createSystemRecordVerifiedReplacementRegistryV1(); + const { input, bindings } = fixture(); + let traps = 0; + const trap = () => { + traps += 1; + throw new Error('proxy trap must not run'); + }; + const proxiedInput = new Proxy(input, { + getPrototypeOf: trap, + ownKeys: trap, + getOwnPropertyDescriptor: trap, + }); + expect(() => registry.issuer.issueActive(proxiedInput)).toThrow(/Proxy/); + expect(traps).toBe(0); + + const proxiedAuthority = new Proxy(input.verifiedAuthoritySummary, { + getPrototypeOf: trap, + get: trap, + }); + expect(() => registry.issuer.issueActive({ + ...input, + verifiedAuthoritySummary: proxiedAuthority, + })).toThrow(/minted by closure verification/); + expect(traps).toBe(0); + + const proxiedSeal = new Proxy(input.head.graphScopedAuthorSeal, { + getPrototypeOf: trap, + ownKeys: trap, + getOwnPropertyDescriptor: trap, + }); + expect(() => registry.issuer.issueActive({ + ...input, + head: { ...input.head, graphScopedAuthorSeal: proxiedSeal }, + })).toThrow(/author seal must not be a Proxy/); + expect(traps).toBe(0); + + const handle = registry.issuer.issueActive(input); + const proxiedBindings = new Proxy(bindings, { + getPrototypeOf: trap, + ownKeys: trap, + getOwnPropertyDescriptor: trap, + }); + expect(() => registry.consumer.consume(handle, proxiedBindings)).toThrow(/Proxy/); + expect(traps).toBe(0); + expect(registry.consumer.consume(handle, bindings).head.state).toBe('active'); + }); + + it('is not exported from the storage package barrel', async () => { + const storage = await import('../src/index.js'); + expect('createSystemRecordVerifiedReplacementRegistryV1' in storage).toBe(false); + }); +}); From 8eb7e9ef1d7e3f626db74c1d93299b26cd3d0e4a Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Fri, 7 Aug 2026 04:45:24 +0200 Subject: [PATCH 2/3] fix(storage): close atomic apply review gaps --- .../system-record-managed-ownership.yml | 1 + docs/adr/0002-system-record-sync-v1.md | 21 +++-- packages/storage/src/adapters/sparql-http.ts | 26 +++++- packages/storage/src/index.ts | 4 +- ...ecord-atomic-apply-executor-v1-internal.ts | 10 ++- .../system-record-apply-command-v1.test.ts | 10 ++- ...em-record-atomic-apply-executor-v1.test.ts | 83 +++++++++++++++++- ...cord-managed-mutation-admission-v1.test.ts | 87 +++++++++++++++++++ 8 files changed, 222 insertions(+), 20 deletions(-) diff --git a/.github/workflows/system-record-managed-ownership.yml b/.github/workflows/system-record-managed-ownership.yml index f58606e787..b8dd92f2a8 100644 --- a/.github/workflows/system-record-managed-ownership.yml +++ b/.github/workflows/system-record-managed-ownership.yml @@ -129,6 +129,7 @@ jobs: test/system-record-capability-discovery-v1.test.ts \ test/system-record-inspection-v1.test.ts \ test/system-record-managed-mutation-admission-v1.test.ts \ + test/system-record-materialization-epoch-adapter-v1.test.ts \ test/system-record-materialization-epoch-v1.test.ts \ test/system-record-next-state-v1.test.ts \ test/system-record-rdf-schema-v1.test.ts \ diff --git a/docs/adr/0002-system-record-sync-v1.md b/docs/adr/0002-system-record-sync-v1.md index 2165d79416..ae2a3ab334 100644 --- a/docs/adr/0002-system-record-sync-v1.md +++ b/docs/adr/0002-system-record-sync-v1.md @@ -861,10 +861,12 @@ scheduler-fenced projection preflight. While holding the exclusive `agents` perm storage reads the exact prior/next subject union from the selected projection graph, incrementally hashes its strict canonical graphless N-Triples lines under `dkg-ka-projection-v1\n`, and compares digest, byte count, and quad count with the -applied snapshot. Canonical line-order failure or any mismatch defers with zero -update dispatch, including an absent snapshot with a pre-existing candidate-subject -row and equal-head projection drift. Inspected prior rows are never enumerated into -the SPARQL update. +applied snapshot. Canonical line-order failure or any present-state mismatch defers +with zero update dispatch, as does a pre-existing candidate-subject row in absent +shadow storage. For absent authoritative state, bounded rows on the exact next-subject +union are legacy content and the initial transaction replaces them. Equal-head +projection drift always defers. Inspected prior rows are never enumerated into the +SPARQL update. The expected-state CAS covers `(stateRevision, appliedStateDigest, headDigest, transitionLineage, @@ -974,8 +976,10 @@ The controller owns at most one aggregate session per store for the enabled `disabled|enabling|enabled|reconciling|disabling|shutdown|unavailable`; transition precedence is `shutdown > disable > recovery/revive > open`. Same-descriptor calls coalesce/idempotently return and incompatible opens reject. `disabled -> enabling` -atomically seals admission before enqueueing its epoch transition. Every queued/running -mutation and V1 call binds activation and child-generation abort scopes at enqueue; +atomically seals admission before enqueueing its epoch transition. Every mutation +enqueued after activation intent and every V1 call binds activation and child-generation +abort scopes at enqueue. A managed mutation already queued on the default-off path is +rechecked at dispatch and rejected if activation has committed in the meantime; transitions cancel queued retired scopes and drain or physically terminate running ones before state changes. A different enabled-set descriptor requires disabling and reopening this same aggregate session; `ontology` never creates a second controller, @@ -1001,8 +1005,9 @@ honor the bound; only ACK/health work in unrelated domains bypasses `agents`. A generation/control transition seals every mutation for the managed store only. The default-off undefined path keeps current O(1) head selection with no metadata allocation/evaluation. Because the scheduler is process-global and disabled-mode work has no store identity, each enable takes one -conservative global watermark. Queued predispatch work may run or be removed/timeout -before dispatch. Active work must physically settle; a logical timeout never decrements +conservative global watermark. Queued predispatch reads may run or be removed/timeout; +managed writes queued before activation fail closed if they reach dispatch after commit. +Active work must physically settle; a logical timeout never decrements the transition watermark. If an untagged active operation cannot be attributed and proven settled, activation fails closed. Regardless of apparent completion, every `disabled -> enabling` transition destroys the old managed HTTP client, stops/proves diff --git a/packages/storage/src/adapters/sparql-http.ts b/packages/storage/src/adapters/sparql-http.ts index 0554417385..463998c6b4 100644 --- a/packages/storage/src/adapters/sparql-http.ts +++ b/packages/storage/src/adapters/sparql-http.ts @@ -285,6 +285,7 @@ export class SparqlHttpStore implements TripleStore { options: QueryOptions | undefined, work: (signal: AbortSignal | undefined) => Promise, mutationBinding?: ManagedMutationBindingV1, + guardUnboundManagedMutation = false, ): Promise { return this.workLifecycle.run( options?.signal, @@ -293,6 +294,7 @@ export class SparqlHttpStore implements TripleStore { options?.source ?? `sparql-http.${operation}`, () => { if (mutationBinding) this.assertManagedMutationBinding(mutationBinding); + else if (guardUnboundManagedMutation) this.assertUnboundManagedMutationStillPermitted(); return work(signal); }, signal, @@ -366,6 +368,18 @@ export class SparqlHttpStore implements TripleStore { } } + /** Refuse a managed write whose admission state changed while it was queued. */ + private assertUnboundManagedMutationStillPermitted(): void { + if (this.managedMutationFailure !== null) { + throw new ManagedOxigraphMutationUnavailableError(this.managedMutationFailure); + } + if (this.systemRecordAdmissionActive) { + throw new ManagedOxigraphMutationUnavailableError( + 'mutation was queued before system-record admission became active', + ); + } + } + /** * Explicit, non-system graph scopes stay outside the `agents` ordering * domain. Unknown/default/system scopes conservatively serialize with the @@ -450,6 +464,11 @@ export class SparqlHttpStore implements TripleStore { // request body with `application/sparql-update`, not URL-encoded form // data. See postQuery for why form encoding breaks large payloads. const mutationBinding = this.createManagedMutationBinding(graphs); + // A managed mutation admitted before activation intentionally remains + // untagged. Recheck it at dispatch so a control barrier cannot enable the + // lane and then release that stale entry under the legacy rules. + const guardUnboundManagedMutation = this.ownershipLease !== null + && mutationBinding === undefined; return this.runStoreWork(operation, options, async (lifecycleSignal) => { const timeoutSignal = AbortSignal.timeout(this.timeout); const signalScope = composeAbortSignals(lifecycleSignal, timeoutSignal); @@ -481,7 +500,7 @@ export class SparqlHttpStore implements TripleStore { } finally { signalScope.dispose(); } - }, mutationBinding); + }, mutationBinding, guardUnboundManagedMutation); } /** @@ -540,8 +559,9 @@ export class SparqlHttpStore implements TripleStore { * Advertising is gated on a LIVE lease, not merely on holding one: a store * whose child has exited still holds the lease object, but its snapshot is * not ready and the lane must not be advertised as usable. The controller - * itself is built lazily and memoized, so a store nobody asks allocates - * nothing and the default-off path stays free. + * itself is built lazily and memoized, so a store nobody asks allocates no + * per-store lane/controller state and the default-off path stays free of + * runtime I/O, scheduling, and timers. */ getSystemRecordLaneControllerV1(): SystemRecordLaneControllerV1 | undefined { // Fail-closed on all three preconditions. A store missing ANY of them is diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 06613776cf..87d5194f26 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -34,7 +34,9 @@ export { type AtomicGraphReplaceUpdate, } from './atomic-graph-replace.js'; // System-record V1 (#2052 Stack B2). Default-unused: these modules perform no -// work until the daemon supervisor supplies a live ownership lease. +// I/O, scheduling, timer, or per-store lane work until the daemon supervisor +// supplies a live ownership lease. Their fixed module-level registries remain +// dormant until then. // Only the members with real consumers are public. `isInternalGraphUriV1` and // `isEphemeralInternalStagingGraphUriV1` stay module-scoped (imported directly // by tests, which pin the reserved/ephemeral partition) rather than being diff --git a/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts b/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts index bc6d797474..9ec7f84c7a 100644 --- a/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts +++ b/packages/storage/src/system-record-atomic-apply-executor-v1-internal.ts @@ -401,7 +401,7 @@ async function executeAdmitted( } try { - if (!matchesProjectionSnapshot(snapshot, exactPrior.projectionQuads)) { + if (!matchesProjectionSnapshot(snapshot, exactPrior.projectionQuads, binding.mode)) { return noMutation({ outcome: 'deferred', reason: 'validation-mismatch' }); } } catch { @@ -485,7 +485,7 @@ async function executeAdmitted( appliedStateDigest: prepared.success.appliedStateDigest, }), }); - } else if (matchesExact(observed, exactPrior)) { + } else if (updateFailure === null && matchesExact(observed, exactPrior)) { return noMutation({ outcome: 'deferred', reason: 'state-changed' }); } else { updateFailure ??= new Error('system-record post-read matched neither prior nor next state'); @@ -1055,8 +1055,14 @@ export function fingerprintSystemRecordProjectionV1( function matchesProjectionSnapshot( snapshot: SystemRecordAppliedSnapshotV1, projectionQuads: readonly Readonly[], + mode: SystemRecordLaneExecutionBindingV1['mode'], ): boolean { const observed = fingerprintSystemRecordProjectionV1(projectionQuads); + // No applied state owns authoritative projection subjects yet, so bounded + // rows on the exact next-subject union are legacy content that the initial + // atomic transaction must replace. Shadow storage is protocol-owned from + // inception and therefore remains strict-empty when its state is absent. + if (snapshot.state === 'absent' && mode === 'authoritative') return true; const expected = snapshot.state === 'present' ? snapshot.appliedState : Object.freeze({ diff --git a/packages/storage/test/system-record-apply-command-v1.test.ts b/packages/storage/test/system-record-apply-command-v1.test.ts index ef25c4f966..003b350a59 100644 --- a/packages/storage/test/system-record-apply-command-v1.test.ts +++ b/packages/storage/test/system-record-apply-command-v1.test.ts @@ -61,11 +61,15 @@ describe('system-record conditional apply command V1', () => { )).toThrow(/not produced by the verified state derivation/); }); - it('executes atomically in Oxigraph and a stale CAS becomes a zero-write miss', async () => { + it('atomically replaces authoritative legacy rows and a stale CAS becomes a zero-write miss', async () => { endpoint = await startOxigraphSparqlEndpoint(); - const { ready } = makeAuthenticActiveReplacementFixtureV1('shadow'); + const { ready } = makeAuthenticActiveReplacementFixtureV1('authoritative'); + const legacySubject = ready.nextSubjects[0]; endpoint.store.update(`INSERT DATA { ${ready.previousReservedQuads.map(renderQuad).join('\n')} + GRAPH <${ready.projectionGraph}> { + <${legacySubject}> "must-be-replaced" . + } }`); const update = buildSystemRecordConditionalApplyUpdateV1(ready); const dispatch = async () => { @@ -85,6 +89,8 @@ describe('system-record conditional apply command V1', () => { const reserved = ready.nextReservedQuads[0]; expect(ask(`GRAPH <${SYSTEM_RECORD_V1_STATE_GRAPH}> { <${reserved.subject}> ` + `<${reserved.predicate}> ${renderObject(reserved.object)} }`)).toBe(true); + expect(ask(`GRAPH <${ready.projectionGraph}> { <${legacySubject}> ` + + ' "must-be-replaced" }')).toBe(false); const guardedSubject = ready.nextSubjects[0]; endpoint.store.update(`INSERT DATA { GRAPH <${ready.projectionGraph}> { diff --git a/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts b/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts index 79a8048c23..85d4cc2d55 100644 --- a/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts +++ b/packages/storage/test/system-record-atomic-apply-executor-v1.test.ts @@ -196,7 +196,7 @@ describe('bounded system-record atomic apply executor V1', () => { expect(fixture.client.updateCalls).toBe(0); }); - it('rejects a pre-existing row on a next-only subject when local state is absent', async () => { + it('atomically replaces a pre-existing legacy row on authoritative cold apply', async () => { const fixture = makeFixture({ priorProjection: () => [{ subject: VERIFIED.head.rootSubject, @@ -210,6 +210,28 @@ describe('bounded system-record atomic apply executor V1', () => { fixture.binding, fixture.registerRecovery, ); + expect(result).toMatchObject({ + settlement: 'settled', + outcome: { outcome: 'applied' }, + }); + expect(fixture.client.updateCalls).toBe(1); + }); + + it('rejects a pre-existing row in absent shadow storage', async () => { + const fixture = makeFixture({ + mode: 'shadow', + priorProjection: () => [{ + subject: VERIFIED.head.rootSubject, + predicate: 'https://schema.org/name', + object: '"pre-existing"', + graph: '', + }], + }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); expect(result).toEqual({ settlement: 'no-mutation', outcome: { outcome: 'deferred', reason: 'validation-mismatch' }, @@ -217,6 +239,51 @@ describe('bounded system-record atomic apply executor V1', () => { expect(fixture.client.updateCalls).toBe(0); }); + it.each([ + ['transport failure', { updateFailure: new Error('timeout') }], + ['non-2xx response', { updateStatus: 500 }], + ] as const)('transfers %s with an immediate exact-prior read to recovery', async ( + _label, + update, + ) => { + const recoveryCompletion = new Promise<{ readonly resolution: 'unavailable' }>(() => undefined); + const fixture = makeFixture({ postState: 'prior', recoveryCompletion, ...update }); + const result = await fixture.executor.execute( + fixture.proof, + fixture.binding, + fixture.registerRecovery, + ); + expect(result).toMatchObject({ + settlement: 'recovery-owned', + outcome: { outcome: 'indeterminate' }, + }); + expect(fixture.client.updateCalls).toBe(1); + const request = fixture.registeredRequest(); + expect(request).toBeDefined(); + const responses = [ + { status: 200, body: selectJson([EPOCH]) }, + { status: 200, body: selectJson([]) }, + ]; + const recoveryClient: SystemRecordAtomicApplyHttpClientV1 = { + childGeneration: '3', + isDestroyed: false, + post: async (_url, _contentType, _body, _timeoutMs, _signal, limits) => { + const response = responses.shift(); + if (!response) throw new Error('unexpected exact-prior recovery request'); + limits?.reserveResponseCapacity?.(Buffer.byteLength(response.body, 'utf8')); + return response; + }, + }; + await expect(request!.reconcile({ + client: recoveryClient, + queryEndpoint: 'http://127.0.0.1:7878/query', + absoluteDeadlineMs: performance.now() + 30_000, + signal: new AbortController().signal, + assertAttributable: () => true, + })).resolves.toEqual({ resolution: 'not-applied' }); + expect(responses).toHaveLength(0); + }); + it('fingerprints a maximum-row projection incrementally in canonical order', () => { const quads = Array.from({ length: 10_000 }, (_, index) => ({ subject: VERIFIED.head.rootSubject, @@ -599,7 +666,10 @@ function makeFixture(options: Readonly<{ verified?: typeof VERIFIED; admittedDeadlineMs?: number; localState?: 'absent' | 'next'; + mode?: SystemRecordLaneExecutionBindingV1['mode']; postState?: 'next' | 'prior' | 'malformed'; + updateFailure?: Error; + updateStatus?: number; rejectRecoveryOwnership?: boolean; order?: string[]; recoveryCompletion?: Promise<{ readonly resolution: 'unavailable' }>; @@ -618,7 +688,7 @@ function makeFixture(options: Readonly<{ activationGeneration: '1', networkId: NETWORK, kind: 'agents', - mode: 'authoritative', + mode: options.mode ?? 'authoritative', sessionIdentity: Object.freeze(Object.create(null) as object), childGeneration: '2', materializationEpoch: '2', @@ -663,7 +733,7 @@ function makeFixture(options: Readonly<{ { status: 200, body: selectJson(initialProjection) }, ]; if (!localNext && admittedDeadlineMs >= 1_500) { - responses.push({ status: 204, body: '' }); + responses.push({ status: options.updateStatus ?? 204, body: '' }); if (options.postState === 'malformed') { responses.push({ status: 200, body: '{' }); } else if (options.postState === 'prior') { @@ -691,6 +761,7 @@ function makeFixture(options: Readonly<{ attributable = false; } }, + options.updateFailure, ); const registry = createSystemRecordVerifiedReplacementRegistryV1(); const proof = registry.issuer.issueActive(issue(binding, admittedDeadlineMs, verified)); @@ -791,6 +862,7 @@ class FakeClient implements SystemRecordAtomicApplyHttpClientV1 { private readonly responses: Array>, private readonly onPost: () => void = () => undefined, private readonly onResponse: (remainingResponses: number) => void = () => undefined, + private readonly updateFailure?: Error, ) {} async post( @@ -807,7 +879,10 @@ class FakeClient implements SystemRecordAtomicApplyHttpClientV1 { limits?.reserveResponseCapacity?.(responseBytes); this.onPost(); this.calls.push({ contentType, body, responseBytes }); - if (contentType.startsWith('application/sparql-update')) this.updateCalls += 1; + if (contentType.startsWith('application/sparql-update')) { + this.updateCalls += 1; + if (this.updateFailure !== undefined) throw this.updateFailure; + } this.onResponse(this.responses.length); return response; } diff --git a/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts b/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts index f0a74410f7..97424ad146 100644 --- a/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts +++ b/packages/storage/test/system-record-managed-mutation-admission-v1.test.ts @@ -139,6 +139,32 @@ describe('managed Oxigraph mutation admission V1', () => { return { entered: entered.promise, release: () => gate.resolve(undefined), work }; } + function holdSchedulerCapacity(): { + readonly entered: Promise; + readonly release: () => void; + readonly work: Promise; + } { + const entered = deferred(); + const gate = deferred(); + const capacity = externalStorePriorityScheduler.snapshot.maxConcurrent; + let enteredCount = 0; + const blockers = Array.from({ length: capacity }, (_, index) => + externalStorePriorityScheduler.run( + 'ack', + `test.managed-mutation.capacity-${index}`, + async () => { + enteredCount += 1; + if (enteredCount === capacity) entered.resolve(undefined); + await gate.promise; + }, + )); + return { + entered: entered.promise, + release: () => gate.resolve(undefined), + work: Promise.all(blockers).then(() => undefined), + }; + } + it('holds system mutations behind an agents exclusive', async () => { await activate(); const exclusive = holdAgentsExclusive(); @@ -167,6 +193,47 @@ describe('managed Oxigraph mutation admission V1', () => { expect(fetchCalls).toBe(0); }); + it('refuses a scoped mutation queued before activation when it reaches dispatch', async () => { + const capacity = holdSchedulerCapacity(); + await capacity.entered; + + const write = store.insert([quad(AGENTS_GRAPH)]); + await drainTurns(); + const activation = activate(); + await drainTurns(); + + capacity.release(); + await capacity.work; + await activation; + + await expect(write).rejects.toMatchObject({ + code: 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE', + }); + expect(fetchCalls).toBe(0); + }); + + it('refuses an opaque update queued before activation when it reaches dispatch', async () => { + const capacity = holdSchedulerCapacity(); + await capacity.entered; + + const write = store.update( + 'INSERT DATA { "x" }', + { touchedGraphs: [UNRELATED_GRAPH] }, + ); + await drainTurns(); + const activation = activate(); + await drainTurns(); + + capacity.release(); + await capacity.work; + await activation; + + await expect(write).rejects.toMatchObject({ + code: 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE', + }); + expect(fetchCalls).toBe(0); + }); + it('keeps an explicit unrelated context-graph mutation concurrent with agents apply', async () => { await activate(); const exclusive = holdAgentsExclusive(); @@ -241,6 +308,26 @@ describe('managed Oxigraph mutation admission V1', () => { expect(fetchCalls).toBe(1); }); + it('does not evaluate managed admission state for an unowned endpoint', async () => { + const unowned = new SparqlHttpStore({ + queryEndpoint: QUERY_ENDPOINT, + updateEndpoint: UPDATE_ENDPOINT, + }); + Object.defineProperty(unowned, 'systemRecordAdmissionActive', { + configurable: true, + get: () => { + throw new Error('unowned update evaluated managed admission state'); + }, + }); + + try { + await expect(unowned.insert([quad(UNRELATED_GRAPH)])).resolves.toBeUndefined(); + expect(fetchCalls).toBe(1); + } finally { + await unowned.close(); + } + }); + it('restores the zero-metadata scheduler fast path after a successful disable', async () => { const controller = store.getSystemRecordLaneControllerV1?.(); expect(controller).toBeDefined(); From 792adfadf371f92c2cba320385a95d76477db6fb Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Sat, 8 Aug 2026 03:47:53 +0200 Subject: [PATCH 3/3] fix(storage): retain managed writer attribution through recovery --- .../system-record-managed-ownership.yml | 3 +- packages/storage/src/adapters/sparql-http.ts | 75 ++++++++++--------- .../src/system-record-materializer-v1.ts | 36 +++++++-- ...aged-backend-ownership-dispatch-v1.test.ts | 24 ++++++ ...m-record-materializer-lifecycle-v1.test.ts | 40 ++++++++++ 5 files changed, 134 insertions(+), 44 deletions(-) diff --git a/.github/workflows/system-record-managed-ownership.yml b/.github/workflows/system-record-managed-ownership.yml index 9efe107d69..05ae13986d 100644 --- a/.github/workflows/system-record-managed-ownership.yml +++ b/.github/workflows/system-record-managed-ownership.yml @@ -163,7 +163,8 @@ jobs: test/graph-set-index-store.test.ts \ test/system-record-decorator-apply-outcomes-v1.test.ts \ test/store-priority-scheduler.test.ts \ - test/store-scheduler-system-record-admission.test.ts + test/store-scheduler-system-record-admission.test.ts \ + test/managed-http-client-v1.test.ts # Every test file this branch adds or modifies now runs SOMEWHERE. # diff --git a/packages/storage/src/adapters/sparql-http.ts b/packages/storage/src/adapters/sparql-http.ts index 8aaaf60c0f..ae3212d733 100644 --- a/packages/storage/src/adapters/sparql-http.ts +++ b/packages/storage/src/adapters/sparql-http.ts @@ -59,10 +59,10 @@ import { ManagedOxigraphBackendUnownedError, extractManagedOxigraphHandoffV1, extractManagedOxigraphLeaseV1, - isManagedOxigraphOwnershipLiveV1, managedOxigraphOwnershipEndpointsMatchV1, readManagedOxigraphOwnershipSnapshotV1, type ManagedOxigraphOwnershipLeaseV1, + type ManagedOxigraphOwnershipSnapshotV1, type ManagedOxigraphSupervisorHandoffV1, } from '../managed-oxigraph-ownership-v1-internal.js'; import { @@ -321,26 +321,17 @@ export class SparqlHttpStore implements TripleStore { } if (!this.systemRecordAdmissionActive) return undefined; const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); - if ( - !snapshot || - snapshot.terminal || - !snapshot.ready || - this.systemRecordHasCredentials || - !managedOxigraphOwnershipEndpointsMatchV1( - snapshot, - this.systemRecordQueryEndpoint, - this.systemRecordUpdateEndpoint, - ) - ) { + const attributable = this.attributableManagedOwnership(snapshot); + if (!attributable) { throw new ManagedOxigraphMutationUnavailableError('ownership is not live and attributable'); } const domain = this.managedMutationDomain(graphs); return Object.freeze({ - generation: snapshot.childGeneration, + generation: attributable.childGeneration, admission: Object.freeze({ storeId: this, - generation: snapshot.childGeneration, + generation: attributable.childGeneration, domain, mode: 'shared' as const, }), @@ -356,17 +347,10 @@ export class SparqlHttpStore implements TripleStore { throw new ManagedOxigraphMutationUnavailableError('ownership lease was revoked'); } const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + const attributable = this.attributableManagedOwnership(snapshot); if ( - !snapshot || - snapshot.terminal || - !snapshot.ready || - snapshot.childGeneration !== binding.generation || - this.systemRecordHasCredentials || - !managedOxigraphOwnershipEndpointsMatchV1( - snapshot, - this.systemRecordQueryEndpoint, - this.systemRecordUpdateEndpoint, - ) + !attributable || + attributable.childGeneration !== binding.generation ) { throw new ManagedOxigraphMutationUnavailableError('child generation changed before dispatch'); } @@ -578,16 +562,17 @@ export class SparqlHttpStore implements TripleStore { * it — lives in `releaseSystemRecordLaneControllerV1`, where the registration * does. */ - private releaseSystemRecordLane(): void { + private releaseSystemRecordLane(quiesceOwner: () => Promise): Promise { const controller = this.systemRecordLane; this.systemRecordLane = null; - if (controller) releaseSystemRecordLaneControllerV1(controller); + if (!controller) return quiesceOwner(); + return releaseSystemRecordLaneControllerV1(controller, quiesceOwner); } private assertManagedBackendReadable(operation: string): void { if (!this.ownershipLease) return; - if (isManagedOxigraphOwnershipLiveV1(this.ownershipLease)) return; const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + if (this.attributableManagedOwnership(snapshot)) return; throw new ManagedOxigraphBackendUnownedError( `sparql-http.${operation}`, snapshot?.terminal ?? false, @@ -597,10 +582,8 @@ export class SparqlHttpStore implements TripleStore { private assertManagedBackendOwned(operation: string): void { if (!this.ownershipLease) return; - if (isManagedOxigraphOwnershipLiveV1(this.ownershipLease)) return; - // Cold path only: the snapshot allocates, so it is taken to build the error - // and nowhere else. const snapshot = readManagedOxigraphOwnershipSnapshotV1(this.ownershipLease); + if (this.attributableManagedOwnership(snapshot)) return; throw new ManagedOxigraphBackendUnownedError( `sparql-http.${operation}`, snapshot?.terminal ?? false, @@ -608,6 +591,24 @@ export class SparqlHttpStore implements TripleStore { ); } + private attributableManagedOwnership( + snapshot: ManagedOxigraphOwnershipSnapshotV1 | null, + ): ManagedOxigraphOwnershipSnapshotV1 | null { + return ( + snapshot && + !snapshot.terminal && + snapshot.ready && + !this.systemRecordHasCredentials && + managedOxigraphOwnershipEndpointsMatchV1( + snapshot, + this.systemRecordQueryEndpoint, + this.systemRecordUpdateEndpoint, + ) + ? snapshot + : null + ); + } + /** * Refuse a caller-authored mutation aimed at persistent system-record V1 * reserved state (#2052 B2). @@ -1448,12 +1449,14 @@ export class SparqlHttpStore implements TripleStore { // Releasing here also latches this store's session terminal, so nothing can // be admitted into a lane whose store is draining; doing it after the await // would leave that window open. - this.releaseSystemRecordLane(); - // A managed endpoint is stopped immediately after store.close(). The - // lifecycle owns one complete generation, aborting and draining every - // operation admitted before close while rejecting work attempted during - // close. A fresh generation is installed only after the drain completes. - await this.workLifecycle.close(new Error('SparqlHttpStore closed')); + await this.releaseSystemRecordLane(() => + // A managed endpoint is stopped immediately after store.close(). The + // lifecycle owns one complete generation, aborting and draining every + // operation admitted before close while rejecting work attempted during + // close. A fresh controller is admitted only after this drain and any + // uncertain-write recovery both settle. + this.workLifecycle.close(new Error('SparqlHttpStore closed')), + ); } } diff --git a/packages/storage/src/system-record-materializer-v1.ts b/packages/storage/src/system-record-materializer-v1.ts index 0c47a8252c..b74b22a220 100644 --- a/packages/storage/src/system-record-materializer-v1.ts +++ b/packages/storage/src/system-record-materializer-v1.ts @@ -389,11 +389,20 @@ export function __resetSystemRecordControllerRegistrationForTests(): void { * control barrier whose failure would make `store.close()` reject. The adapter * never owned the child, so it makes no claim about it. */ -export function releaseSystemRecordLaneControllerV1( +export async function releaseSystemRecordLaneControllerV1( controller: SystemRecordLaneControllerV1, -): void { - CONTROLLER_SESSIONS.get(controller)?.detach(); + quiesceOwner: () => Promise = () => Promise.resolve(), +): Promise { + const session = CONTROLLER_SESSIONS.get(controller); + const recoverySettlement = session?.detach() ?? Promise.resolve(); + // Detach is synchronous up to its first return: new lane work is refused + // before owner quiescence starts. The global slot remains claimed until BOTH + // the store is drained and any already-owned uncertain-write recovery has + // physically settled. Promise.all also observes the recovery if quiescence + // fails, while deliberately retaining the registration on either failure. + await Promise.all([quiesceOwner(), recoverySettlement]); if (registeredController === controller) registeredController = null; + CONTROLLER_SESSIONS.delete(controller); } export function createSystemRecordLaneControllerV1( @@ -1637,15 +1646,28 @@ class SystemRecordLaneSession { * the committed state is `detached` and not `shutdown`. Only `shutdown` * claims the child was proven dead, and a detach cannot make that claim. * - * It does NOT join an in-flight transition. Shutdown must, because it is about - * to touch the child; detach touches nothing, and awaiting a stalled enable - * inside `store.close()` would hang the close on a stalled supervisor. + * It does not join an ordinary open. It does join a transition that already + * owns uncertain-write recovery, because releasing the process-global writer + * slot before that recovery physically settles would admit a second writer + * over the same managed child. */ - detach(): void { + detach(): Promise { // A completed or in-flight shutdown made the STRONGER claim; overwriting it // with `detached` would downgrade the record of what was established. if (this.readState() !== 'shutdown') this.current = 'detached'; this.descriptor = null; + const transition = this.transition; + const recovery = this.recoveryOf(transition); + if (transition === null || recovery === null || recovery.settled) { + return Promise.resolve(); + } + return this.transitionSettlement(transition).then(() => { + if (!recovery.settled) { + throw new Error( + 'system-record controller detach could not prove uncertain-write recovery settled', + ); + } + }); } /** diff --git a/packages/storage/test/managed-backend-ownership-dispatch-v1.test.ts b/packages/storage/test/managed-backend-ownership-dispatch-v1.test.ts index 7e7c05bc56..8f4f05eb0f 100644 --- a/packages/storage/test/managed-backend-ownership-dispatch-v1.test.ts +++ b/packages/storage/test/managed-backend-ownership-dispatch-v1.test.ts @@ -178,6 +178,30 @@ describe('managed backend ownership at mutation dispatch', () => { await plain.close().catch(() => undefined); }); + it('refuses a default-off managed store whose endpoints do not match its lease', async () => { + const store = await createTripleStore({ + backend: 'sparql-http', + options: attachManagedOxigraphLeaseV1( + { + queryEndpoint: 'http://127.0.0.1:7904/query', + updateEndpoint: 'http://127.0.0.1:7904/update', + managedByDkg: true, + }, + ownership.lease, + supervisor, + ) as unknown as Record, + graphSetIndex: false, + }); + + await expect(store.insert([QUAD])).rejects.toThrow(/not the proven ready listener/); + await expect(store.query('ASK { ?s ?p ?o }')).rejects.toThrow( + /not the proven ready listener/, + ); + expect(requests).toEqual([]); + + await store.close().catch(() => undefined); + }); + it('hides reserved internal graphs from adapter-level hasGraph', async () => { // The policy module claims reserved state never enumerates and that "no // legitimate iterate-and-drop loop can reach one". That held only for the 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 e52b9401a1..8534aace69 100644 --- a/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts +++ b/packages/storage/test/system-record-materializer-lifecycle-v1.test.ts @@ -10,6 +10,7 @@ import { SystemRecordLaneActivationConflictError, __resetSystemRecordControllerRegistrationForTests, createSystemRecordLaneControllerV1, + releaseSystemRecordLaneControllerV1, type SystemRecordApplyOutcomeV1, type SystemRecordChildHandoffV1, type SystemRecordLaneActivationV1, @@ -1875,6 +1876,45 @@ describe('system-record lane session lifecycle V1', () => { })); }); + it('retains the single-writer registration until detached recovery settles', async () => { + const { controller, recoveryExecutor } = buildRecovery(); + const session = await controller.open(ACTIVATION); + recoveryExecutor.parkReconcile(); + + await expect(session.applyVerified({})).resolves.toEqual({ + outcome: 'indeterminate', + recoveryGeneration: '1', + }); + await recoveryExecutor.reconcileReached; + + let released = false; + const release = releaseSystemRecordLaneControllerV1(controller).then(() => { + released = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + + expect(released).toBe(false); + expect(() => + createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor: new StubExecutor(), + barrier: barrier.run, + }), + ).toThrow(SystemRecordControllerRegistrationError); + + recoveryExecutor.releaseReconcile(); + await release; + + const replacement = createSystemRecordLaneControllerV1({ + lease: ownership.lease, + handoff: new RecordingHandoff(), + executor: new StubExecutor(), + barrier: barrier.run, + }); + await releaseSystemRecordLaneControllerV1(replacement); + }); + it('accepts an attributable exact result that returns after its dispatch deadline', async () => { const { controller, recoveryExecutor } = buildRecovery(); const session = await controller.open(ACTIVATION);