diff --git a/devnet/rfc64-m1-selective-coverage/README.md b/devnet/rfc64-m1-selective-coverage/README.md new file mode 100644 index 0000000000..558fd20fe7 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/README.md @@ -0,0 +1,179 @@ +# RFC-64 M1 selective coverage evidence + +This directory defines and launches the closed, deterministic evidence boundary +for the M1 three-process devnet harness. An operator-owned adapter controls real +Publisher, Edge, and Core processes; the repository-owned collector controls +the phase order, checks process identity, and refuses to publish an artifact +until the fail-closed verifier accepts every observation. + +The verifier passes only when all of these user-visible outcomes are proven: + +- the corpus digest, network, exact Git/runtime manifests, and three distinct + process peer IDs match a trust anchor supplied outside the evidence artifact; +- Publisher-owned source snapshots match the anchored corpus, so a receiver + cannot redefine the VM or SWM state it is expected to receive; +- an Edge has no VM or SWM payload before the user selects a graph; +- an on-demand selection receives an exact point-in-time VM and SWM snapshot + but does not advance after restart without another request; +- an always-on selection receives the first exact snapshot and advances to the + second exact snapshot after restart; +- unselected public and private graphs remain payload-free on the Edge; +- automatic Core rounds never exceed the configured batch and never include a + private graph; +- every public graph is eventually scheduled and converges to exact final VM + and SWM heads, inventory digests, asset counts, and payload triple counts. + +The corpus may contain up to 64 graphs, while one automatic journal entry may +contain at most 32 graph IDs. Accordingly, `coreAutomaticBatchSize` is capped at +32 and a 33-64 graph corpus must converge through multiple bounded rounds. A +truncated single-round claim cannot satisfy the gate. + +Edge results are bound to runtime subscription modes and distinct operation job +IDs whose completion records carry the exact resulting snapshot. After restart, +always-on work must come from the reconciler; on-demand payload remains at its +first snapshot but its process-local mode is absent until a second explicit user +request reactivates and advances it. Core results are +bound to scheduler-issued automatic jobs with an empty explicit selection list +and exact final-wave per-graph completion records. Every public graph must first +appear within the anchored coverage-round limit. Manual catch-up cannot be +relabeled as automatic evidence. + +Metadata is allowed to exist for an excluded graph because chain and discovery +metadata are not corpus payload. Metadata-only responses can never satisfy a +required plane: `reportedComplete` is treated as an assertion, and the verifier +independently requires exact nonzero data, counts, heads, and inventory roots. + +## Runtime sequence + +The launcher deliberately starts Core cold, after the second publication wave: + +1. Start Publisher and Edge as distinct OS processes and verify their peer, + network, commit, and loaded-runtime identities against the trust anchor. +2. Publish the selected wave and read exact Publisher VM and SWM snapshots. +3. Prove the Edge has no payload, then issue only the anchored on-demand and + always-on user selections and retain their returned job IDs. +4. Publish the final wave, restart the Edge into a new OS process, and require + its actual reconciler record for always-on refresh. Observe on-demand data + still at the selected snapshot with no active runtime mode. +5. Issue the second explicit on-demand request and require exact final state. +6. Start Core cold, consume scheduler-issued automatic rounds only, and require + every public graph to enter the bounded window and reach exact final state. + +The collector always stops every role it attempted to start. If collection, +node cleanup, adapter shutdown, or adapter exit fails, it does not write a +passing evidence artifact. Artifact publication therefore happens only after +the controlling adapter and all controlled node processes have shut down. + +## Exact plane observations + +For each VM and SWM plane the adapter queries the complete scoped KA inventory +and builds `Rfc64SemanticSnapshotV1` using +`devnet/_bootstrap/rfc64-evidence.ts`. The mapping is fixed: + +- `headDigest` = `ualsSha256`; +- `inventoryDigest` = `semanticNQuadsSha256`; +- `assetCount` = `kaCount`; +- `dataTripleCount` = `quadCount`; +- `metadataTripleCount` = the separately queried scoped metadata row count. + +An empty or metadata-only plane must use the absent representation; it must not +set `reportedComplete=true`. The adapter may not derive the expected snapshot +from Edge or Core. Expected snapshots come from the immutable corpus file and +must match Publisher-owned observations. + +## Operator adapter protocol + +Set `DKG_RFC64_M1_ADAPTER_COMMAND` and, optionally, +`DKG_RFC64_M1_ADAPTER_ARGS_JSON` to an executable that reads one JSON command +per stdin line. It may write ordinary logs, but evidence results use exactly: + +```text +DKG_RFC64_M1_RESULT {"schema":"dkg-rfc64-m1-selective-coverage-runtime-result-v1",...} +``` + +Every command carries the runtime protocol, a launcher-generated 256-bit +`sessionNonce`, a monotonic `sequence`, a command name, and a payload. The +adapter must return the same nonce and sequence with either +`{"ok":true,"value":...}` or `{"ok":false,"error":"..."}`. A result or log +line larger than 1 MiB is rejected, preventing an unbounded stdout buffer. +Commands are: + +| Command | Required runtime action/evidence | +| --- | --- | +| `start` | Start the named role; independently return PID, process instance/wave IDs, `processStartedAt` as integer epoch milliseconds sourced from `Math.floor(performance.timeOrigin)`, durable-directory identity, peer ID, network, commit, and loaded-runtime digest. The command contains only the role, never the trust anchor. | +| `publish-wave` | Publish the named anchored wave; return exact Publisher VM/SWM observations for every graph. | +| `observe-edge` | Return exact Edge VM/SWM observations, effective runtime mode, and the actual producing job ID. | +| `synchronize-edge` | Issue only the named explicit user selection; return its real job ID and terminal exact snapshot. | +| `restart-edge` | Stop and restart Edge from the same durable data directory; return a receipt binding the old process instance/PID and observed exit to the new process instance/PID, stable directory identity, and peer identity. | +| `wait-edge-reconciler` | Wait/read only; return source, mode, job ID, completed snapshot, and the post-restart journal reference without receiving those conclusions in the command. | +| `core-automatic-round` | Return `round` with the frozen scheduler plan and a journal snapshot/reference proving its actual planned IDs, lane, job ID, empty explicit selection, and per-CG terminal completions. | +| `observe-core-final` | Return exact final VM/SWM observations and the automatic job IDs that produced each graph. | +| `stop` / `shutdown` | Stop one role / close the controlling adapter. Acknowledgement means all controlled node processes have exited; the launcher waits for adapter exit, then escalates from `SIGTERM` to `SIGKILL` on timeout. | + +The adapter reads automatic provenance from the node-admin-only endpoint +`GET /api/diagnostics/sync-coverage-evidence?afterSequence=N`. The launcher +requires schema version 1, the exact 256-entry journal capacity, an in-window +terminal entry, `evidenceTruncated=false`, +and all metadata/durable/shared-memory verification bits. It binds: + +- `edge-reconciler-job` entries to the actual job ID, context graph, + `source=reconciler`, `trigger=periodic-reconciler`, and + `syncMode=always-on`; +- `core-automatic-round` entries to the actual job ID, planning lane, + configured batch, frozen explicit/automatic ID lists, and every terminal + per-CG completion. Every completion carries the same real scheduler-round + job ID; a detached or synthetic per-CG ID is rejected. Planned IDs and + completion IDs must match exactly in count and order, so later completion + cannot retroactively validate an incomplete earlier admission round. + +`droppedBeforeSequence` and `nextSequence` prove the selected entry was not +overwritten. Any truncated, missing, nonterminal, or mismatched record fails the +run. Synthetic job IDs or inference from final store contents are not acceptable +substitutes. The admin subscriptions response supplies the effective Edge +`syncMode`; omission retains the legacy `always-on` interpretation only inside +the node, never as evidence for a requested on-demand selection. + +The exact bounded journal snapshots and selected sequence numbers are retained +in the canonical artifact alongside the Edge/Core process-start and wave +identities. `verify-live` parses and revalidates those raw records, so an artifact +with relabeled automatic fields, synthetic job IDs, missing journal proof, or a +mismatched process wave is rejected independently of the collection process. + +Every adapter response is decoded through a closed per-command schema at the +process boundary before orchestration can consume it. Wrong session/protocol, +unknown sequences, malformed JSON, oversized lines, unexpected keys, and +malformed command values all fail closed. The launcher also has a direct +regression test proving that corpus, trust-anchor, and artifact paths are absent +from the spawned adapter environment. + +This repository supplies the fail-closed orchestrator and framed adapter +protocol, not a deployment-specific adapter executable. The live command is +therefore intentionally blocked unless `DKG_RFC64_M1_ADAPTER_COMMAND` names an +operator-reviewed implementation. The launcher removes corpus, trust-anchor, +and output paths from the adapter environment; the adapter must report runtime +identity from the processes it launched, not echo expected values. + +## Running the live gate + +The corpus and trust anchor are separate, pre-existing operator inputs. The +launcher cleans and rebuilds the runtime closure, recomputes its manifest, and +requires it to match the trust anchor before starting any node: + +```sh +export DKG_RFC64_M1_CORPUS_FILE=/secure/operator/m1-corpus.json +export DKG_RFC64_M1_TRUST_ANCHOR_FILE=/secure/operator/m1-trust-anchor.json +export DKG_RFC64_M1_ADAPTER_COMMAND=/secure/operator/dkg-m1-adapter +export DKG_RFC64_M1_ADAPTER_ARGS_JSON='[]' +pnpm test:m1:rfc64-selective-coverage +``` + +The trust anchor contains `networkId`, exact `testedHeadCommit`, computed +`runtimeManifestDigest`, `corpusManifestDigest`, and the three expected peer +IDs. Do not generate or overwrite it from receiver output during the run. + +Run the bounded contract checks with: + +```sh +pnpm test:m1:rfc64-selective-coverage:unit +pnpm typecheck:m1:rfc64-selective-coverage +``` diff --git a/devnet/rfc64-m1-selective-coverage/adapter-environment.test.ts b/devnet/rfc64-m1-selective-coverage/adapter-environment.test.ts new file mode 100644 index 0000000000..d03789a500 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/adapter-environment.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildSelectiveCoverageAdapterEnvironment } from './adapter-environment.ts'; + +test('strips immutable operator inputs from the spawned adapter environment', () => { + const parent = { + NODE_ENV: 'development', + DKG_RFC64_M1_CORPUS_FILE: '/secure/corpus.json', + DKG_RFC64_M1_TRUST_ANCHOR_FILE: '/secure/trust-anchor.json', + DKG_RFC64_M1_ARTIFACT: '/secure/pass-artifact.json', + PRESERVED_ADAPTER_SETTING: 'present', + }; + const result = buildSelectiveCoverageAdapterEnvironment(parent); + + assert.equal(result.NODE_ENV, 'production'); + assert.equal(result.PRESERVED_ADAPTER_SETTING, 'present'); + assert.equal(Object.hasOwn(result, 'DKG_RFC64_M1_CORPUS_FILE'), false); + assert.equal(Object.hasOwn(result, 'DKG_RFC64_M1_TRUST_ANCHOR_FILE'), false); + assert.equal(Object.hasOwn(result, 'DKG_RFC64_M1_ARTIFACT'), false); + assert.equal(parent.DKG_RFC64_M1_TRUST_ANCHOR_FILE, '/secure/trust-anchor.json'); +}); diff --git a/devnet/rfc64-m1-selective-coverage/adapter-environment.ts b/devnet/rfc64-m1-selective-coverage/adapter-environment.ts new file mode 100644 index 0000000000..509be441a1 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/adapter-environment.ts @@ -0,0 +1,14 @@ +const PRIVATE_LAUNCHER_INPUTS = Object.freeze([ + 'DKG_RFC64_M1_CORPUS_FILE', + 'DKG_RFC64_M1_TRUST_ANCHOR_FILE', + 'DKG_RFC64_M1_ARTIFACT', +]); + +/** Keep immutable expectations outside the operator adapter's process boundary. */ +export function buildSelectiveCoverageAdapterEnvironment( + parent: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...parent, NODE_ENV: 'production' }; + for (const name of PRIVATE_LAUNCHER_INPUTS) delete env[name]; + return env; +} diff --git a/devnet/rfc64-m1-selective-coverage/boundary-codec.test.ts b/devnet/rfc64-m1-selective-coverage/boundary-codec.test.ts new file mode 100644 index 0000000000..4355a032e2 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/boundary-codec.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + boundedString, + closedArray, + closedRecord, + identifier, + nonNegativeInteger, + plainRecord, + positiveInteger, + requireDecoded, +} from './boundary-codec.ts'; + +test('shared record primitives keep open and closed JSON boundaries distinct', () => { + assert.deepEqual(plainRecord({ required: 1, future: true }), { required: 1, future: true }); + assert.deepEqual(closedRecord({ required: 1, optional: 2 }, ['required'], ['optional']), { + required: 1, + optional: 2, + }); + assert.equal(closedRecord({ required: 1, future: true }, ['required']), undefined); + + const hidden = { required: 1 }; + Object.defineProperty(hidden, 'hidden', { value: true, enumerable: false }); + const accessor = {}; + Object.defineProperty(accessor, 'required', { enumerable: true, get: () => 1 }); + const symbol = { required: 1 } as Record; + symbol[Symbol('hidden')] = true; + for (const value of [hidden, accessor, symbol, Object.create(null)]) { + assert.equal(plainRecord(value), undefined); + } +}); + +test('shared array, string, identifier, and integer primitives are fail closed', () => { + assert.equal(closedArray([1, 2], 1, 2), true); + const sparse = new Array(1); + const extended: unknown[] & { extra?: boolean } = [1]; + extended.extra = true; + const accessor = [1]; + Object.defineProperty(accessor, '0', { enumerable: true, get: () => 1 }); + for (const value of [sparse, extended, accessor]) { + assert.equal(closedArray(value, 0, 2), false); + } + + assert.equal(boundedString('runtime text', 1, 4_096), 'runtime text'); + assert.equal(identifier('peer:@/id'), 'peer:@/id'); + assert.equal(identifier('contains space'), undefined); + assert.equal(nonNegativeInteger(0), true); + assert.equal(nonNegativeInteger(-1), false); + assert.equal(positiveInteger(1), true); + assert.equal(positiveInteger(0), false); +}); + +test('shared throwing adapter preserves its labeled boundary', () => { + assert.equal(requireDecoded('value', 'test boundary'), 'value'); + assert.throws(() => requireDecoded(undefined, 'test boundary'), /Invalid test boundary/u); +}); diff --git a/devnet/rfc64-m1-selective-coverage/boundary-codec.ts b/devnet/rfc64-m1-selective-coverage/boundary-codec.ts new file mode 100644 index 0000000000..18c4b55a00 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/boundary-codec.ts @@ -0,0 +1,94 @@ +/** Shared fail-closed primitives for every M1 JSON trust boundary. */ + +export type PlainDataRecord = Record; + +/** + * Declare every string field of a model exactly once. Type-checking fails when + * the model gains a field until the closed boundary descriptor is updated. + */ +export function defineRecordKeys() { + return []>( + ...keys: Exclude, Keys[number]> extends never ? Keys : never + ): Keys => keys; +} + +const IDENTIFIER = /^[A-Za-z0-9._:/@-]+$/u; + +/** Accept an ordinary object whose own fields are enumerable data properties. */ +export function plainRecord(value: unknown): PlainDataRecord | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value) + || Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => typeof key !== 'string')) return undefined; + if ((ownKeys as string[]).some((key) => { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return !descriptor?.enumerable || !('value' in descriptor); + })) return undefined; + return value as PlainDataRecord; +} + +/** Accept a closed ordinary object with required and explicitly optional keys. */ +export function closedRecord( + value: unknown, + requiredKeys: readonly string[], + optionalKeys: readonly string[] = [], +): PlainDataRecord | undefined { + const row = plainRecord(value); + if (!row) return undefined; + const allowed = new Set([...requiredKeys, ...optionalKeys]); + const actual = Object.keys(row); + if (actual.some((key) => !allowed.has(key)) + || requiredKeys.some((key) => !Object.hasOwn(row, key))) return undefined; + return row; +} + +/** Accept a dense ordinary array with no custom or accessor properties. */ +export function closedArray( + value: unknown, + minimum: number, + maximum: number, +): value is unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype + || value.length < minimum || value.length > maximum) return false; + const expected = new Set(['length']); + for (let index = 0; index < value.length; index += 1) expected.add(String(index)); + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== expected.size || ownKeys.some((key) => !expected.has(key))) return false; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !('value' in descriptor)) return false; + } + return true; +} + +export function boundedString( + value: unknown, + minimum: number, + maximum: number, + pattern?: RegExp, +): string | undefined { + return typeof value === 'string' + && value.length >= minimum + && value.length <= maximum + && (pattern === undefined || pattern.test(value)) + ? value + : undefined; +} + +export function identifier(value: unknown): string | undefined { + return boundedString(value, 1, 256, IDENTIFIER); +} + +export function nonNegativeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +export function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +/** Translate an undefined-returning decoder into a labeled throwing boundary. */ +export function requireDecoded(value: T | undefined, label: string): T { + if (value === undefined) throw new TypeError(`Invalid ${label}`); + return value; +} diff --git a/devnet/rfc64-m1-selective-coverage/edge-operation-plan.ts b/devnet/rfc64-m1-selective-coverage/edge-operation-plan.ts new file mode 100644 index 0000000000..1c061dc362 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/edge-operation-plan.ts @@ -0,0 +1,149 @@ +import { + type EdgeSyncOperationV1, + type GraphSnapshotExpectationV1, + type SelectiveCoverageCorpusV1, +} from './manifest.ts'; + +export type EdgeOperationPhaseV1 = EdgeSyncOperationV1['phase']; +export type EdgeSnapshotSelectorV1 = 'selectedSnapshot' | 'finalSnapshot'; + +export interface EdgeOperationPlanStepV1< + Phase extends EdgeOperationPhaseV1 = EdgeOperationPhaseV1, +> { + readonly sequence: number; + readonly phase: Phase; + readonly source: EdgeSyncOperationV1['source']; + readonly syncMode: EdgeSyncOperationV1['syncMode']; + readonly contextGraphId: string; + readonly completedWave: EdgeSyncOperationV1['completedWave']; + readonly snapshotSelector: EdgeSnapshotSelectorV1; + readonly completedSnapshot: GraphSnapshotExpectationV1; +} + +export interface EdgeOperationPlanV1 { + readonly ordered: readonly EdgeOperationPlanStepV1[]; + readonly selection: readonly EdgeOperationPlanStepV1<'selection'>[]; + readonly postRestartAutomatic: readonly EdgeOperationPlanStepV1<'post-restart-auto'>[]; + readonly postRestartExplicit: readonly EdgeOperationPlanStepV1<'post-restart-explicit'>[]; +} + +/** + * Derive the one canonical Edge state-machine plan from the anchored corpus. + * + * Corpus order is preserved within each phase. The collector executes these + * phase slices, while the verifier compares evidence against `ordered`, so a + * new phase or policy branch cannot silently drift between the two. + */ +export function buildEdgeOperationPlan( + corpus: SelectiveCoverageCorpusV1, +): EdgeOperationPlanV1 { + const selection: EdgeOperationPlanStepV1<'selection'>[] = []; + const postRestartAutomatic: EdgeOperationPlanStepV1<'post-restart-auto'>[] = []; + const postRestartExplicit: EdgeOperationPlanStepV1<'post-restart-explicit'>[] = []; + + for (const graph of corpus.graphs) { + if (graph.accessPolicy !== 0 || graph.edgePolicy === 'unselected') continue; + selection.push({ + sequence: selection.length, + phase: 'selection', + source: 'user', + syncMode: graph.edgePolicy, + contextGraphId: graph.contextGraphId, + completedWave: 'selected', + snapshotSelector: 'selectedSnapshot', + completedSnapshot: graph.selectedSnapshot, + }); + } + + for (const graph of corpus.graphs) { + if (graph.accessPolicy === 0 && graph.edgePolicy === 'always-on') { + postRestartAutomatic.push({ + sequence: selection.length + postRestartAutomatic.length, + phase: 'post-restart-auto', + source: 'reconciler', + syncMode: 'always-on', + contextGraphId: graph.contextGraphId, + completedWave: 'final', + snapshotSelector: 'finalSnapshot', + completedSnapshot: graph.finalSnapshot, + }); + } + } + + for (const graph of corpus.graphs) { + if (graph.accessPolicy === 0 && graph.edgePolicy === 'on-demand') { + postRestartExplicit.push({ + sequence: selection.length + + postRestartAutomatic.length + + postRestartExplicit.length, + phase: 'post-restart-explicit', + source: 'user', + syncMode: 'on-demand', + contextGraphId: graph.contextGraphId, + completedWave: 'final', + snapshotSelector: 'finalSnapshot', + completedSnapshot: graph.finalSnapshot, + }); + } + } + + const frozenSelection = freezeSteps(selection); + const frozenAutomatic = freezeSteps(postRestartAutomatic); + const frozenExplicit = freezeSteps(postRestartExplicit); + return Object.freeze({ + ordered: Object.freeze([ + ...frozenSelection, + ...frozenAutomatic, + ...frozenExplicit, + ]), + selection: frozenSelection, + postRestartAutomatic: frozenAutomatic, + postRestartExplicit: frozenExplicit, + }); +} + +/** Compare an observed operation to a plan step, excluding its runtime job ID. */ +export function matchesEdgeOperationPlanStep( + operation: EdgeSyncOperationV1 | undefined, + step: EdgeOperationPlanStepV1, +): boolean { + return operation?.sequence === step.sequence + && operation.phase === step.phase + && operation.source === step.source + && operation.syncMode === step.syncMode + && operation.contextGraphId === step.contextGraphId + && operation.completedWave === step.completedWave + && exactSnapshot(operation.completedSnapshot, step.completedSnapshot); +} + +export function findEdgeOperationPlanStep( + plan: EdgeOperationPlanV1, + contextGraphId: string, + phase: EdgeOperationPhaseV1, +): EdgeOperationPlanStepV1 | undefined { + return plan.ordered.find((step) => + step.contextGraphId === contextGraphId && step.phase === phase); +} + +function freezeSteps( + steps: readonly EdgeOperationPlanStepV1[], +): readonly EdgeOperationPlanStepV1[] { + return Object.freeze(steps.map((step) => Object.freeze(step))); +} + +function exactSnapshot( + left: GraphSnapshotExpectationV1, + right: GraphSnapshotExpectationV1, +): boolean { + return exactPlane(left.vm, right.vm) && exactPlane(left.swm, right.swm); +} + +function exactPlane( + left: GraphSnapshotExpectationV1['vm'], + right: GraphSnapshotExpectationV1['vm'], +): boolean { + return left.headDigest === right.headDigest + && left.inventoryDigest === right.inventoryDigest + && left.assetCount === right.assetCount + && left.dataTripleCount === right.dataTripleCount; +} diff --git a/devnet/rfc64-m1-selective-coverage/evidence-codec.ts b/devnet/rfc64-m1-selective-coverage/evidence-codec.ts new file mode 100644 index 0000000000..90bd6d2c92 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/evidence-codec.ts @@ -0,0 +1,541 @@ +import { + MAX_SELECTIVE_COVERAGE_GRAPHS, + MAX_SELECTIVE_COVERAGE_ROUNDS, + MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY, + SELECTIVE_COVERAGE_CORPUS_SCHEMA, + SELECTIVE_COVERAGE_EVIDENCE_SCHEMA, + type CoreAutomaticCompletionV1, + type CoreAutomaticRoundV1, + type CoreFinalObservationV1, + type EdgeCoveragePolicy, + type EdgeGraphObservationV1, + type EdgeSyncOperationV1, + type ExpectedSelectiveCoverageProvenanceV1, + type GraphObservationV1, + type GraphSnapshotExpectationV1, + type PlaneExpectationV1, + type PlaneObservationV1, + type SelectiveCoverageCorpusV1, + type SelectiveCoverageAutomaticJournalEvidenceV1, + type SelectiveCoverageEvidenceV1, + type SelectiveCoverageGraphV1, + type SelectiveCoverageProvenanceV1, + type SyncCoverageJournalProcessIdentityV1, + type SyncCoverageJournalReferenceV1, +} from './manifest.ts'; +import { parseSyncCoverageJournalReferenceV1 } from './sync-coverage-journal.ts'; +import { + closedArray, + closedRecord, + defineRecordKeys, + identifier, + nonNegativeInteger, + positiveInteger, +} from './boundary-codec.ts'; + +const DIGEST = /^(?:0x|sha256:)[0-9a-f]{64}$/u; + +type PublisherEvidenceV1 = SelectiveCoverageEvidenceV1['publisher']; +type EdgeEvidenceV1 = SelectiveCoverageEvidenceV1['edge']; +type CoreEvidenceV1 = SelectiveCoverageEvidenceV1['core']; + +const SELECTIVE_COVERAGE_EVIDENCE_KEYS = defineRecordKeys()( + 'schema', + 'provenance', + 'automaticJournalEvidence', + 'corpus', + 'publisher', + 'edge', + 'core', +); +const SELECTIVE_COVERAGE_PROVENANCE_KEYS = defineRecordKeys< + SelectiveCoverageProvenanceV1 +>()( + 'networkId', + 'testedHeadCommit', + 'runtimeManifestDigest', + 'publisherPeerId', + 'edgePeerId', + 'corePeerId', +); +const EXPECTED_SELECTIVE_COVERAGE_PROVENANCE_KEYS = defineRecordKeys< + ExpectedSelectiveCoverageProvenanceV1 +>()( + ...SELECTIVE_COVERAGE_PROVENANCE_KEYS, + 'corpusManifestDigest', +); +const AUTOMATIC_JOURNAL_EVIDENCE_KEYS = defineRecordKeys< + SelectiveCoverageAutomaticJournalEvidenceV1 +>()( + 'edgeProcess', + 'edgeReconciler', + 'coreProcess', + 'coreRounds', +); +const JOURNAL_PROCESS_IDENTITY_KEYS = defineRecordKeys< + SyncCoverageJournalProcessIdentityV1 +>()('processStartedAt', 'evidenceWaveId'); +const SELECTIVE_COVERAGE_CORPUS_KEYS = defineRecordKeys()( + 'schema', + 'networkId', + 'coreAutomaticBatchSize', + 'coreCoverageRoundLimit', + 'graphs', + 'manifestDigest', +); +const SELECTIVE_COVERAGE_GRAPH_KEYS = defineRecordKeys()( + 'contextGraphId', + 'accessPolicy', + 'publishPolicy', + 'edgePolicy', + 'selectedSnapshot', + 'finalSnapshot', +); +const PUBLISHER_EVIDENCE_KEYS = defineRecordKeys()('selected', 'final'); +const EDGE_EVIDENCE_KEYS = defineRecordKeys()( + 'beforeSelection', + 'afterSelection', + 'afterRestart', + 'afterSecondOnDemand', + 'operations', +); +const CORE_EVIDENCE_KEYS = defineRecordKeys()( + 'automaticBatchSize', + 'rounds', + 'final', +); +const GRAPH_OBSERVATION_KEYS = defineRecordKeys()( + 'contextGraphId', + 'vm', + 'swm', +); +const EDGE_GRAPH_OBSERVATION_KEYS = defineRecordKeys()( + 'contextGraphId', + 'runtimeSyncMode', + 'producingJobId', + 'vm', + 'swm', +); +const CORE_FINAL_OBSERVATION_KEYS = defineRecordKeys()( + 'contextGraphId', + 'automaticJobIds', + 'vm', + 'swm', +); +const GRAPH_SNAPSHOT_EXPECTATION_KEYS = defineRecordKeys()( + 'vm', + 'swm', +); +const EDGE_SYNC_OPERATION_PAYLOAD_KEYS = defineRecordKeys< + Omit +>()( + 'phase', + 'source', + 'syncMode', + 'contextGraphId', + 'jobId', + 'completedWave', + 'completedSnapshot', +); +const EDGE_SYNC_OPERATION_KEYS = defineRecordKeys()( + 'sequence', + ...EDGE_SYNC_OPERATION_PAYLOAD_KEYS, +); +const CORE_AUTOMATIC_ROUND_KEYS = defineRecordKeys()( + 'round', + 'jobId', + 'planningLane', + 'source', + 'configuredBatchSize', + 'explicitSelectedContextGraphIds', + 'contextGraphIds', + 'completions', +); +const CORE_AUTOMATIC_COMPLETION_KEYS = defineRecordKeys()( + 'contextGraphId', + 'completedWave', + 'completedSnapshot', +); +const PLANE_EXPECTATION_KEYS = defineRecordKeys()( + 'headDigest', + 'inventoryDigest', + 'assetCount', + 'dataTripleCount', +); +const PLANE_OBSERVATION_KEYS = defineRecordKeys()( + 'reportedComplete', + 'headDigest', + 'inventoryDigest', + 'assetCount', + 'metadataTripleCount', + 'dataTripleCount', +); + +/** Canonical closed-schema decoder shared by artifact and process boundaries. */ +export function decodeSelectiveCoverageEvidence( + input: unknown, +): SelectiveCoverageEvidenceV1 | undefined { + const root = closedRecord(input, SELECTIVE_COVERAGE_EVIDENCE_KEYS); + if (!root || root.schema !== SELECTIVE_COVERAGE_EVIDENCE_SCHEMA) return undefined; + const provenance = parseProvenance(root.provenance); + const automaticJournalEvidence = parseAutomaticJournalEvidence(root.automaticJournalEvidence); + const corpus = decodeSelectiveCoverageCorpus(root.corpus); + const publisher = closedRecord(root.publisher, PUBLISHER_EVIDENCE_KEYS); + const edge = closedRecord(root.edge, EDGE_EVIDENCE_KEYS); + const core = closedRecord(root.core, CORE_EVIDENCE_KEYS); + if (!provenance || !automaticJournalEvidence || !corpus || !publisher || !edge || !core + || !nonNegativeInteger(core.automaticBatchSize) + || !closedArray(core.rounds, 1, MAX_SELECTIVE_COVERAGE_ROUNDS)) return undefined; + const publisherSelected = decodeGraphObservations(publisher.selected); + const publisherFinal = decodeGraphObservations(publisher.final); + const beforeSelection = decodeEdgeObservations(edge.beforeSelection); + const afterSelection = decodeEdgeObservations(edge.afterSelection); + const afterRestart = decodeEdgeObservations(edge.afterRestart); + const afterSecondOnDemand = decodeEdgeObservations(edge.afterSecondOnDemand); + const operations = parseEdgeOperations(edge.operations); + const final = decodeCoreFinalObservations(core.final); + if (!publisherSelected || !publisherFinal || !beforeSelection || !afterSelection + || !afterRestart || !afterSecondOnDemand || !operations || !final) return undefined; + const rounds: CoreAutomaticRoundV1[] = []; + for (let index = 0; index < core.rounds.length; index += 1) { + const round = decodeCoreAutomaticRound(core.rounds[index]); + if (!round || round.round !== index) return undefined; + rounds.push(round); + } + return { + schema: SELECTIVE_COVERAGE_EVIDENCE_SCHEMA, + provenance, + automaticJournalEvidence, + corpus, + publisher: { selected: publisherSelected, final: publisherFinal }, + edge: { + beforeSelection, + afterSelection, + afterRestart, + afterSecondOnDemand, + operations, + }, + core: { + automaticBatchSize: core.automaticBatchSize as number, + rounds: Object.freeze(rounds), + final, + }, + }; +} + +export function decodeExpectedSelectiveCoverageProvenance( + input: unknown, +): ExpectedSelectiveCoverageProvenanceV1 | undefined { + const root = closedRecord(input, EXPECTED_SELECTIVE_COVERAGE_PROVENANCE_KEYS); + if (!root) return undefined; + const { corpusManifestDigest: _omitted, ...provenanceInput } = root; + const provenance = parseProvenance(provenanceInput); + const corpusManifestDigest = digest(root.corpusManifestDigest); + return provenance && corpusManifestDigest + ? { ...provenance, corpusManifestDigest } + : undefined; +} + +export function decodeGraphObservations( + input: unknown, +): readonly GraphObservationV1[] | undefined { + if (!closedArray(input, 1, MAX_SELECTIVE_COVERAGE_GRAPHS)) return undefined; + const result: GraphObservationV1[] = []; + for (const inputRow of input) { + const row = closedRecord(inputRow, GRAPH_OBSERVATION_KEYS); + const contextGraphId = row && identifier(row.contextGraphId); + const vm = row && parseObservation(row.vm); + const swm = row && parseObservation(row.swm); + if (!contextGraphId || !vm || !swm) return undefined; + result.push({ contextGraphId, vm, swm }); + } + return Object.freeze(result); +} + +export function decodeEdgeObservations( + input: unknown, +): readonly EdgeGraphObservationV1[] | undefined { + if (!closedArray(input, 1, MAX_SELECTIVE_COVERAGE_GRAPHS)) return undefined; + const result: EdgeGraphObservationV1[] = []; + for (const inputRow of input) { + const row = closedRecord(inputRow, EDGE_GRAPH_OBSERVATION_KEYS); + if (!row) return undefined; + const contextGraphId = identifier(row.contextGraphId); + const vm = parseObservation(row.vm); + const swm = parseObservation(row.swm); + const runtimeSyncMode = row.runtimeSyncMode; + const producingJobId = row.producingJobId === null ? null : identifier(row.producingJobId); + if (!contextGraphId || !vm || !swm || producingJobId === undefined + || (runtimeSyncMode !== null && runtimeSyncMode !== 'on-demand' + && runtimeSyncMode !== 'always-on')) return undefined; + result.push({ contextGraphId, runtimeSyncMode, producingJobId, vm, swm }); + } + return Object.freeze(result); +} + +export function decodeCoreFinalObservations( + input: unknown, +): readonly CoreFinalObservationV1[] | undefined { + if (!closedArray(input, 1, MAX_SELECTIVE_COVERAGE_GRAPHS)) return undefined; + const result: CoreFinalObservationV1[] = []; + for (const inputRow of input) { + const row = closedRecord(inputRow, CORE_FINAL_OBSERVATION_KEYS); + if (!row || !closedArray(row.automaticJobIds, 0, MAX_SELECTIVE_COVERAGE_ROUNDS)) { + return undefined; + } + const contextGraphId = identifier(row.contextGraphId); + const vm = parseObservation(row.vm); + const swm = parseObservation(row.swm); + const automaticJobIds = row.automaticJobIds.map(identifier); + if (!contextGraphId || !vm || !swm || automaticJobIds.some((value) => !value) + || new Set(automaticJobIds).size !== automaticJobIds.length) return undefined; + result.push({ + contextGraphId, + automaticJobIds: Object.freeze(automaticJobIds as string[]), + vm, + swm, + }); + } + return Object.freeze(result); +} + +export function decodeGraphSnapshot( + input: unknown, +): GraphSnapshotExpectationV1 | undefined { + const root = closedRecord(input, GRAPH_SNAPSHOT_EXPECTATION_KEYS); + if (!root) return undefined; + const vm = parseExpectation(root.vm); + const swm = parseExpectation(root.swm); + return vm && swm ? { vm, swm } : undefined; +} + +/** Shared closed decoder for the Edge operation artifact and runtime adapter. */ +export function decodeEdgeSyncOperationPayload( + input: unknown, +): Omit | undefined { + const row = closedRecord(input, EDGE_SYNC_OPERATION_PAYLOAD_KEYS); + return row ? parseEdgeSyncOperationRecord(row) : undefined; +} + +export function decodeCoreAutomaticRound(input: unknown): CoreAutomaticRoundV1 | undefined { + const row = closedRecord(input, CORE_AUTOMATIC_ROUND_KEYS); + if (!row || !nonNegativeInteger(row.round) + || row.source !== 'automatic-core-public' + || !positiveInteger(row.configuredBatchSize) + || !closedArray(row.explicitSelectedContextGraphIds, 0, MAX_SELECTIVE_COVERAGE_GRAPHS) + || !closedArray(row.contextGraphIds, 0, MAX_SELECTIVE_COVERAGE_GRAPHS) + || !closedArray(row.completions, 0, MAX_SELECTIVE_COVERAGE_GRAPHS)) return undefined; + const jobId = identifier(row.jobId); + const planningLane = identifier(row.planningLane); + const explicitSelectedContextGraphIds = row.explicitSelectedContextGraphIds.map(identifier); + const contextGraphIds = row.contextGraphIds.map(identifier); + if (!jobId || !planningLane + || explicitSelectedContextGraphIds.some((value) => !value) + || contextGraphIds.some((value) => !value)) return undefined; + const completions = []; + for (const inputCompletion of row.completions) { + const completion = closedRecord(inputCompletion, CORE_AUTOMATIC_COMPLETION_KEYS); + const contextGraphId = completion && identifier(completion.contextGraphId); + const completedSnapshot = completion && decodeGraphSnapshot(completion.completedSnapshot); + if (!completion || completion.completedWave !== 'final' + || !contextGraphId || !completedSnapshot) return undefined; + completions.push({ contextGraphId, completedWave: 'final' as const, completedSnapshot }); + } + return { + round: row.round, + jobId, + planningLane, + source: 'automatic-core-public', + configuredBatchSize: row.configuredBatchSize, + explicitSelectedContextGraphIds: Object.freeze(explicitSelectedContextGraphIds as string[]), + contextGraphIds: Object.freeze(contextGraphIds as string[]), + completions: Object.freeze(completions), + }; +} + +function parseAutomaticJournalEvidence( + input: unknown, +): SelectiveCoverageEvidenceV1['automaticJournalEvidence'] | undefined { + const root = closedRecord(input, AUTOMATIC_JOURNAL_EVIDENCE_KEYS); + if (!root + || !closedArray(root.edgeReconciler, 0, MAX_SELECTIVE_COVERAGE_GRAPHS) + || !closedArray(root.coreRounds, 1, MAX_SELECTIVE_COVERAGE_ROUNDS)) return undefined; + const edgeProcess = parseJournalProcessIdentity(root.edgeProcess); + const coreProcess = parseJournalProcessIdentity(root.coreProcess); + const edgeReconciler = root.edgeReconciler.map(parseSyncCoverageJournalReferenceV1); + const coreRounds = root.coreRounds.map(parseSyncCoverageJournalReferenceV1); + if (!edgeProcess || !coreProcess + || edgeReconciler.some((entry) => entry === undefined) + || coreRounds.some((entry) => entry === undefined)) return undefined; + return { + edgeProcess, + edgeReconciler: Object.freeze(edgeReconciler as SyncCoverageJournalReferenceV1[]), + coreProcess, + coreRounds: Object.freeze(coreRounds as SyncCoverageJournalReferenceV1[]), + }; +} + +function parseJournalProcessIdentity( + input: unknown, +): SyncCoverageJournalProcessIdentityV1 | undefined { + const root = closedRecord(input, JOURNAL_PROCESS_IDENTITY_KEYS); + const evidenceWaveId = root && identifier(root.evidenceWaveId); + if (!root || !nonNegativeInteger(root.processStartedAt) || !evidenceWaveId) return undefined; + return { processStartedAt: root.processStartedAt, evidenceWaveId }; +} + +export function decodeSelectiveCoverageCorpus( + input: unknown, +): SelectiveCoverageCorpusV1 | undefined { + const root = closedRecord(input, SELECTIVE_COVERAGE_CORPUS_KEYS); + if (!root || root.schema !== SELECTIVE_COVERAGE_CORPUS_SCHEMA) return undefined; + const networkId = identifier(root.networkId); + const manifestDigest = digest(root.manifestDigest); + if (!networkId || !manifestDigest || !positiveInteger(root.coreAutomaticBatchSize) + || root.coreAutomaticBatchSize > MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY + || !positiveInteger(root.coreCoverageRoundLimit) + || root.coreCoverageRoundLimit > MAX_SELECTIVE_COVERAGE_ROUNDS + || !closedArray(root.graphs, 1, MAX_SELECTIVE_COVERAGE_GRAPHS)) return undefined; + const graphs: SelectiveCoverageGraphV1[] = []; + for (const inputGraph of root.graphs) { + const graph = closedRecord(inputGraph, SELECTIVE_COVERAGE_GRAPH_KEYS); + if (!graph) return undefined; + const contextGraphId = identifier(graph.contextGraphId); + const accessPolicy = binaryPolicy(graph.accessPolicy); + const publishPolicy = binaryPolicy(graph.publishPolicy); + const edgePolicy = parseEdgePolicy(graph.edgePolicy); + const selectedSnapshot = decodeGraphSnapshot(graph.selectedSnapshot); + const finalSnapshot = decodeGraphSnapshot(graph.finalSnapshot); + if (!contextGraphId || accessPolicy === undefined || publishPolicy === undefined + || !edgePolicy || !selectedSnapshot || !finalSnapshot + || (accessPolicy === 1 && edgePolicy !== 'unselected')) return undefined; + graphs.push({ + contextGraphId, + accessPolicy, + publishPolicy, + edgePolicy, + selectedSnapshot, + finalSnapshot, + }); + } + return { + schema: SELECTIVE_COVERAGE_CORPUS_SCHEMA, + networkId, + coreAutomaticBatchSize: root.coreAutomaticBatchSize, + coreCoverageRoundLimit: root.coreCoverageRoundLimit, + graphs: Object.freeze(graphs), + manifestDigest, + }; +} + +function parseProvenance(input: unknown): SelectiveCoverageEvidenceV1['provenance'] | undefined { + const root = closedRecord(input, SELECTIVE_COVERAGE_PROVENANCE_KEYS); + if (!root) return undefined; + const networkId = identifier(root.networkId); + const runtimeManifestDigest = digest(root.runtimeManifestDigest); + const publisherPeerId = identifier(root.publisherPeerId); + const edgePeerId = identifier(root.edgePeerId); + const corePeerId = identifier(root.corePeerId); + if (!networkId || typeof root.testedHeadCommit !== 'string' + || !/^[0-9a-f]{40,64}$/u.test(root.testedHeadCommit) + || !runtimeManifestDigest || !publisherPeerId || !edgePeerId || !corePeerId + || new Set([publisherPeerId, edgePeerId, corePeerId]).size !== 3) return undefined; + return { + networkId, + testedHeadCommit: root.testedHeadCommit, + runtimeManifestDigest, + publisherPeerId, + edgePeerId, + corePeerId, + }; +} + +function parseEdgeOperations(input: unknown): readonly EdgeSyncOperationV1[] | undefined { + if (!closedArray(input, 1, MAX_SELECTIVE_COVERAGE_GRAPHS * 2)) return undefined; + const result: EdgeSyncOperationV1[] = []; + for (let index = 0; index < input.length; index += 1) { + const row = closedRecord(input[index], EDGE_SYNC_OPERATION_KEYS); + const operation = row && parseEdgeSyncOperationRecord(row); + if (!row || row.sequence !== index || !operation) return undefined; + result.push({ + sequence: index, + ...operation, + }); + } + return Object.freeze(result); +} + +function parseEdgeSyncOperationRecord( + row: Record, +): Omit | undefined { + const phase = row.phase; + const source = row.source; + const syncMode = row.syncMode; + const contextGraphId = identifier(row.contextGraphId); + const jobId = identifier(row.jobId); + const completedWave = row.completedWave; + const completedSnapshot = decodeGraphSnapshot(row.completedSnapshot); + if ((phase !== 'selection' && phase !== 'post-restart-auto' + && phase !== 'post-restart-explicit') + || (source !== 'reconciler' && source !== 'user') + || (syncMode !== 'always-on' && syncMode !== 'on-demand') + || (completedWave !== 'selected' && completedWave !== 'final') + || !completedSnapshot || !contextGraphId || !jobId) return undefined; + return { + phase, + source, + syncMode, + contextGraphId, + jobId, + completedWave, + completedSnapshot, + }; +} + +function parseExpectation(input: unknown): PlaneExpectationV1 | undefined { + const root = closedRecord(input, PLANE_EXPECTATION_KEYS); + if (!root) return undefined; + const headDigest = digest(root.headDigest); + const inventoryDigest = digest(root.inventoryDigest); + if (!headDigest || !inventoryDigest || !positiveInteger(root.assetCount) + || !positiveInteger(root.dataTripleCount)) return undefined; + return { + headDigest, + inventoryDigest, + assetCount: root.assetCount, + dataTripleCount: root.dataTripleCount, + }; +} + +function parseObservation(input: unknown): PlaneObservationV1 | undefined { + const root = closedRecord(input, PLANE_OBSERVATION_KEYS); + if (!root || typeof root.reportedComplete !== 'boolean' + || !nonNegativeInteger(root.assetCount) + || !nonNegativeInteger(root.metadataTripleCount) + || !nonNegativeInteger(root.dataTripleCount)) return undefined; + const headDigest = root.headDigest === null ? null : digest(root.headDigest); + const inventoryDigest = root.inventoryDigest === null ? null : digest(root.inventoryDigest); + if (headDigest === undefined || inventoryDigest === undefined) return undefined; + return { + reportedComplete: root.reportedComplete, + headDigest, + inventoryDigest, + assetCount: root.assetCount, + metadataTripleCount: root.metadataTripleCount, + dataTripleCount: root.dataTripleCount, + }; +} + +function digest(value: unknown): string | undefined { + return typeof value === 'string' && DIGEST.test(value) ? value : undefined; +} + +function binaryPolicy(value: unknown): 0 | 1 | undefined { + return value === 0 || value === 1 ? value : undefined; +} + +function parseEdgePolicy(value: unknown): EdgeCoveragePolicy | undefined { + return value === 'on-demand' || value === 'always-on' || value === 'unselected' + ? value + : undefined; +} diff --git a/devnet/rfc64-m1-selective-coverage/launch-live.ts b/devnet/rfc64-m1-selective-coverage/launch-live.ts new file mode 100644 index 0000000000..c765e3f688 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/launch-live.ts @@ -0,0 +1,106 @@ +import { resolve } from 'node:path'; + +import { + atomicWriteExactBytes, + readCleanRepositoryHead, +} from '../rfc64-persistence-lifecycle/evidence.ts'; +import { + buildGate2RuntimeManifestV1, + runGate2CleanRuntimeBuildV1, +} from '../rfc64-gate2-multi-asset-completeness/runtime-provenance.ts'; +import { + canonicalJson, +} from './manifest.ts'; +import { ProcessSelectiveCoverageRuntimeV1 } from './process-runtime.ts'; +import { collectSelectiveCoverageEvidenceV1 } from './runtime.ts'; +import { runSelectiveCoverageLiveV1 } from './live-runner.ts'; +import { buildSelectiveCoverageAdapterEnvironment } from './adapter-environment.ts'; +import { + readExpectedSelectiveCoverageProvenance, + readSelectiveCoverageCorpus, +} from './operator-input.ts'; + +const repoRoot = resolve(import.meta.dirname, '../..'); +const corpusPath = resolveRequiredPath('DKG_RFC64_M1_CORPUS_FILE'); +const trustAnchorPath = resolveRequiredPath('DKG_RFC64_M1_TRUST_ANCHOR_FILE'); +const artifactPath = resolve( + process.env['DKG_RFC64_M1_ARTIFACT'] + ?? resolve(import.meta.dirname, 'artifacts/selective-coverage-evidence.json'), +); +const adapterCommand = requiredEnvironment('DKG_RFC64_M1_ADAPTER_COMMAND'); +const adapterArgs = parseStringArray( + process.env['DKG_RFC64_M1_ADAPTER_ARGS_JSON'] ?? '[]', + 'DKG_RFC64_M1_ADAPTER_ARGS_JSON', +); +const adapterCwd = resolve(process.env['DKG_RFC64_M1_ADAPTER_CWD'] ?? repoRoot); +const timeoutMs = parseTimeout(process.env['DKG_RFC64_M1_ADAPTER_TIMEOUT_MS']); + +const corpus = readSelectiveCoverageCorpus(corpusPath); +const expectedProvenance = readExpectedSelectiveCoverageProvenance(trustAnchorPath); +const sourceCommit = readCleanRepositoryHead(repoRoot); +if (sourceCommit !== expectedProvenance.testedHeadCommit) { + throw new Error('M1 trust anchor names a different checked-out source commit'); +} +runGate2CleanRuntimeBuildV1(repoRoot); +if (readCleanRepositoryHead(repoRoot) !== sourceCommit) { + throw new Error('M1 source HEAD changed during the clean runtime build'); +} +const runtimeManifest = buildGate2RuntimeManifestV1(repoRoot, sourceCommit); +if (runtimeManifest.manifestDigest !== expectedProvenance.runtimeManifestDigest) { + throw new Error('M1 trust anchor names a different clean runtime manifest'); +} + +const runtime = new ProcessSelectiveCoverageRuntimeV1({ + command: adapterCommand, + args: adapterArgs, + cwd: adapterCwd, + timeoutMs, + env: buildSelectiveCoverageAdapterEnvironment(process.env), +}); +await runSelectiveCoverageLiveV1({ + collect: () => collectSelectiveCoverageEvidenceV1({ + corpus, + expectedProvenance, + runtime, + }), + close: () => runtime.close(), + publish: (evidence) => { + const bytes = Buffer.from(`${canonicalJson(evidence)}\n`, 'utf8'); + const published = atomicWriteExactBytes(artifactPath, bytes); + process.stdout.write( + `[rfc64-m1] PASS ${artifactPath} sha256:${published.sha256}\n`, + ); + }, +}); + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function resolveRequiredPath(name: string): string { + return resolve(requiredEnvironment(name)); +} + +function parseStringArray(value: string, label: string): string[] { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`${label} must be a JSON string array`, { cause: error }); + } + if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) { + throw new TypeError(`${label} must be a JSON string array`); + } + return parsed; +} + +function parseTimeout(value: string | undefined): number | undefined { + if (value === undefined || value.trim() === '') return undefined; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new TypeError('DKG_RFC64_M1_ADAPTER_TIMEOUT_MS must be an integer'); + } + return parsed; +} diff --git a/devnet/rfc64-m1-selective-coverage/live-runner.ts b/devnet/rfc64-m1-selective-coverage/live-runner.ts new file mode 100644 index 0000000000..f2787e9bc9 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/live-runner.ts @@ -0,0 +1,35 @@ +/** + * Publish only after both collection and controller shutdown succeed. This + * keeps a fresh PASS artifact from surviving a failed process cleanup. + */ +export async function runSelectiveCoverageLiveV1(input: { + readonly collect: () => Promise; + readonly close: () => Promise; + readonly publish: (value: T) => Promise | void; +}): Promise { + let value: T | undefined; + let collectionFailure: unknown; + try { + value = await input.collect(); + } catch (error) { + collectionFailure = error; + } + + let closeFailure: unknown; + try { + await input.close(); + } catch (error) { + closeFailure = error; + } + + if (collectionFailure !== undefined && closeFailure !== undefined) { + throw new AggregateError( + [collectionFailure, closeFailure], + 'M1 collection and runtime shutdown both failed', + ); + } + if (collectionFailure !== undefined) throw collectionFailure; + if (closeFailure !== undefined) throw closeFailure; + if (value === undefined) throw new Error('M1 collection returned no evidence'); + await input.publish(value); +} diff --git a/devnet/rfc64-m1-selective-coverage/manifest.ts b/devnet/rfc64-m1-selective-coverage/manifest.ts new file mode 100644 index 0000000000..4aca28eea9 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/manifest.ts @@ -0,0 +1,248 @@ +import { createHash } from 'node:crypto'; +import { compactSafeIntegerJson } from '../rfc64-persistence-lifecycle/evidence.ts'; + +export const SELECTIVE_COVERAGE_CORPUS_SCHEMA = + 'dkg-rfc64-m1-selective-coverage-corpus-v1' as const; +export const SELECTIVE_COVERAGE_EVIDENCE_SCHEMA = + 'dkg-rfc64-m1-selective-coverage-evidence-v1' as const; +export const SELECTIVE_COVERAGE_VERDICT_SCHEMA = + 'dkg-rfc64-m1-selective-coverage-verdict-v1' as const; + +export const MAX_SELECTIVE_COVERAGE_GRAPHS = 64; +export const MAX_SELECTIVE_COVERAGE_ROUNDS = 256; +/** Matches the bounded producer journal; larger corpora span multiple rounds. */ +export const MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY = 32; + +export type EdgeCoveragePolicy = 'always-on' | 'on-demand' | 'unselected'; + +export interface PlaneExpectationV1 { + readonly headDigest: string; + readonly inventoryDigest: string; + readonly assetCount: number; + readonly dataTripleCount: number; +} + +export interface GraphSnapshotExpectationV1 { + readonly vm: PlaneExpectationV1; + readonly swm: PlaneExpectationV1; +} + +export interface SelectiveCoverageGraphV1 { + readonly contextGraphId: string; + readonly accessPolicy: 0 | 1; + readonly publishPolicy: 0 | 1; + readonly edgePolicy: EdgeCoveragePolicy; + /** Snapshot present when the Edge selection is first exercised. */ + readonly selectedSnapshot: GraphSnapshotExpectationV1; + /** Snapshot after a second publication wave and an Edge restart. */ + readonly finalSnapshot: GraphSnapshotExpectationV1; +} + +export interface SelectiveCoverageCorpusV1 { + readonly schema: typeof SELECTIVE_COVERAGE_CORPUS_SCHEMA; + readonly networkId: string; + readonly coreAutomaticBatchSize: number; + /** Maximum automatic rounds allowed before every public graph is first admitted. */ + readonly coreCoverageRoundLimit: number; + /** Lexicographically ordered by contextGraphId. */ + readonly graphs: readonly SelectiveCoverageGraphV1[]; + /** SHA-256 of the closed corpus payload, excluding this field. */ + readonly manifestDigest: string; +} + +export interface PlaneObservationV1 { + readonly reportedComplete: boolean; + readonly headDigest: string | null; + readonly inventoryDigest: string | null; + readonly assetCount: number; + readonly metadataTripleCount: number; + readonly dataTripleCount: number; +} + +export interface GraphObservationV1 { + readonly contextGraphId: string; + readonly vm: PlaneObservationV1; + readonly swm: PlaneObservationV1; +} + +export interface EdgeGraphObservationV1 extends GraphObservationV1 { + /** Mode read from the running Edge, not copied from the corpus manifest. */ + readonly runtimeSyncMode: 'always-on' | 'on-demand' | null; + /** Operation that produced the observed payload; null when payload is absent. */ + readonly producingJobId: string | null; +} + +export interface EdgeSyncOperationV1 { + readonly sequence: number; + readonly phase: 'selection' | 'post-restart-auto' | 'post-restart-explicit'; + readonly source: 'reconciler' | 'user'; + readonly syncMode: 'always-on' | 'on-demand'; + readonly contextGraphId: string; + readonly jobId: string; + readonly completedWave: 'selected' | 'final'; + readonly completedSnapshot: GraphSnapshotExpectationV1; +} + +export interface CoreAutomaticCompletionV1 { + readonly contextGraphId: string; + readonly completedWave: 'final'; + readonly completedSnapshot: GraphSnapshotExpectationV1; +} + +export interface CoreAutomaticRoundV1 { + readonly round: number; + readonly jobId: string; + readonly planningLane: string; + readonly source: 'automatic-core-public'; + readonly configuredBatchSize: number; + /** Must remain empty: selected Core work is outside automatic coverage evidence. */ + readonly explicitSelectedContextGraphIds: readonly string[]; + /** Automatic coverage only. Explicit selections are deliberately outside this cap. */ + readonly contextGraphIds: readonly string[]; + /** Exact terminal states produced by this automatic job after the final wave. */ + readonly completions: readonly CoreAutomaticCompletionV1[]; +} + +export interface CoreFinalObservationV1 extends GraphObservationV1 { + /** Scheduler-issued automatic job IDs that produced this graph's final state. */ + readonly automaticJobIds: readonly string[]; +} + +/** Bounded raw node-admin journal response retained in the published artifact. */ +export interface SyncCoverageJournalReferenceV1 { + readonly snapshot: unknown; + readonly sequence: number; +} + +export interface SyncCoverageJournalProcessIdentityV1 { + readonly processStartedAt: number; + readonly evidenceWaveId: string; +} + +export interface SelectiveCoverageAutomaticJournalEvidenceV1 { + readonly edgeProcess: SyncCoverageJournalProcessIdentityV1; + /** Ordered exactly like post-restart automatic Edge operations. */ + readonly edgeReconciler: readonly SyncCoverageJournalReferenceV1[]; + readonly coreProcess: SyncCoverageJournalProcessIdentityV1; + /** Ordered exactly like Core automatic rounds. */ + readonly coreRounds: readonly SyncCoverageJournalReferenceV1[]; +} + +export interface SelectiveCoverageProvenanceV1 { + readonly networkId: string; + readonly testedHeadCommit: string; + readonly runtimeManifestDigest: string; + readonly publisherPeerId: string; + readonly edgePeerId: string; + readonly corePeerId: string; +} + +export interface ExpectedSelectiveCoverageProvenanceV1 + extends SelectiveCoverageProvenanceV1 { + readonly corpusManifestDigest: string; +} + +export interface SelectiveCoverageEvidenceV1 { + readonly schema: typeof SELECTIVE_COVERAGE_EVIDENCE_SCHEMA; + readonly provenance: SelectiveCoverageProvenanceV1; + /** Raw automatic-work proof required for independent artifact verification. */ + readonly automaticJournalEvidence: SelectiveCoverageAutomaticJournalEvidenceV1; + readonly corpus: SelectiveCoverageCorpusV1; + /** Publisher-owned source snapshots; receivers cannot define their expectations. */ + readonly publisher: { + readonly selected: readonly GraphObservationV1[]; + readonly final: readonly GraphObservationV1[]; + }; + readonly edge: { + readonly beforeSelection: readonly EdgeGraphObservationV1[]; + readonly afterSelection: readonly EdgeGraphObservationV1[]; + readonly afterRestart: readonly EdgeGraphObservationV1[]; + readonly afterSecondOnDemand: readonly EdgeGraphObservationV1[]; + readonly operations: readonly EdgeSyncOperationV1[]; + }; + readonly core: { + readonly automaticBatchSize: number; + readonly rounds: readonly CoreAutomaticRoundV1[]; + readonly final: readonly CoreFinalObservationV1[]; + }; +} + +export interface SelectiveCoverageChecksV1 { + readonly schemaWellFormed: boolean; + readonly provenanceMatches: boolean; + readonly corpusDigestMatches: boolean; + readonly corpusCanonicalOrder: boolean; + readonly requiredPolicyCellsPresent: boolean; + readonly publisherSnapshotsExact: boolean; + readonly publicSecondWaveAdvances: boolean; + readonly edgePassiveBeforeSelection: boolean; + readonly edgeSelectedSnapshotsExact: boolean; + readonly edgeOnDemandRemainsPointInTime: boolean; + readonly edgeAlwaysOnRefreshesAfterRestart: boolean; + readonly edgeOperationProvenance: boolean; + readonly edgeSecondOnDemandConverges: boolean; + readonly edgeUnselectedExcluded: boolean; + readonly edgePrivateExcluded: boolean; + readonly coreBatchMatchesManifest: boolean; + readonly coreBatchWithinBound: boolean; + readonly coreRoundsPublicOnly: boolean; + readonly coreAutomaticProvenance: boolean; + readonly coreEveryPublicScheduled: boolean; + readonly coreCoverageWithinWindow: boolean; + readonly coreFinalPublicExact: boolean; + readonly corePrivateExcluded: boolean; + readonly noMetadataOnlyCompletion: boolean; +} + +export interface SelectiveCoverageVerdictV1 { + readonly schema: typeof SELECTIVE_COVERAGE_VERDICT_SCHEMA; + readonly pass: boolean; + readonly checks: SelectiveCoverageChecksV1; + readonly missingCoreContextGraphIds: readonly string[]; + readonly rejectReasons: readonly string[]; + readonly recomputedCorpusDigest: string; +} + +type CorpusPayload = Omit; + +/** Construct a byte-deterministic manifest and normalize graph order once. */ +export function createSelectiveCoverageCorpus(input: { + networkId: string; + coreAutomaticBatchSize: number; + coreCoverageRoundLimit: number; + graphs: readonly SelectiveCoverageGraphV1[]; +}): SelectiveCoverageCorpusV1 { + if (!Number.isSafeInteger(input.coreAutomaticBatchSize) + || input.coreAutomaticBatchSize < 1 + || input.coreAutomaticBatchSize > MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY) { + throw new RangeError('Core automatic batch exceeds one bounded journal entry'); + } + const payload: CorpusPayload = { + schema: SELECTIVE_COVERAGE_CORPUS_SCHEMA, + networkId: input.networkId, + coreAutomaticBatchSize: input.coreAutomaticBatchSize, + coreCoverageRoundLimit: input.coreCoverageRoundLimit, + graphs: [...input.graphs].sort((left, right) => + compareCodeUnits(left.contextGraphId, right.contextGraphId)), + }; + return Object.freeze({ + ...payload, + manifestDigest: computeSelectiveCoverageCorpusDigest(payload), + }); +} + +export function computeSelectiveCoverageCorpusDigest( + corpus: CorpusPayload | SelectiveCoverageCorpusV1, +): string { + const { manifestDigest: _ignored, ...payload } = corpus as SelectiveCoverageCorpusV1; + return `sha256:${createHash('sha256').update(canonicalJson(payload)).digest('hex')}`; +} + +/** Stable JSON is also used by the future process launcher when publishing artifacts. */ +export function canonicalJson(value: unknown): string { + return compactSafeIntegerJson(value); +} + +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/devnet/rfc64-m1-selective-coverage/operator-input.test.ts b/devnet/rfc64-m1-selective-coverage/operator-input.test.ts new file mode 100644 index 0000000000..2327b13d6e --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/operator-input.test.ts @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + createSelectiveCoverageCorpus, + type ExpectedSelectiveCoverageProvenanceV1, +} from './manifest.ts'; +import { + readExpectedSelectiveCoverageProvenance, + readSelectiveCoverageCorpus, +} from './operator-input.ts'; + +const digest = `sha256:${'a'.repeat(64)}`; +const snapshot = { + vm: { headDigest: digest, inventoryDigest: digest, assetCount: 1, dataTripleCount: 1 }, + swm: { headDigest: digest, inventoryDigest: digest, assetCount: 1, dataTripleCount: 1 }, +}; +const corpus = createSelectiveCoverageCorpus({ + networkId: 'otp:20430', + coreAutomaticBatchSize: 1, + coreCoverageRoundLimit: 1, + graphs: [{ + contextGraphId: '0x1111111111111111111111111111111111111111/public-open', + accessPolicy: 0, + publishPolicy: 1, + edgePolicy: 'on-demand', + selectedSnapshot: snapshot, + finalSnapshot: { vm: { ...snapshot.vm }, swm: { ...snapshot.swm } }, + }], +}); +const provenance: ExpectedSelectiveCoverageProvenanceV1 = { + networkId: corpus.networkId, + testedHeadCommit: 'b'.repeat(40), + runtimeManifestDigest: digest, + corpusManifestDigest: corpus.manifestDigest, + publisherPeerId: 'publisher-peer', + edgePeerId: 'edge-peer', + corePeerId: 'core-peer', +}; + +test('operator input readers return only closed decoded corpus and provenance', (context) => { + const directory = mkdtempSync(join(tmpdir(), 'rfc64-m1-input-')); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const corpusPath = join(directory, 'corpus.json'); + const provenancePath = join(directory, 'provenance.json'); + writeFileSync(corpusPath, JSON.stringify(corpus)); + writeFileSync(provenancePath, JSON.stringify(provenance)); + + assert.deepEqual(readSelectiveCoverageCorpus(corpusPath), corpus); + assert.deepEqual(readExpectedSelectiveCoverageProvenance(provenancePath), provenance); +}); + +test('operator input readers label malformed JSON and schema failures', (context) => { + const directory = mkdtempSync(join(tmpdir(), 'rfc64-m1-input-')); + context.after(() => rmSync(directory, { recursive: true, force: true })); + const corpusPath = join(directory, 'corpus.json'); + const provenancePath = join(directory, 'provenance.json'); + writeFileSync(corpusPath, JSON.stringify({ ...corpus, unexpected: true })); + writeFileSync(provenancePath, '{'); + + assert.throws( + () => readSelectiveCoverageCorpus(corpusPath), + /M1 selective-coverage corpus failed closed-schema validation/u, + ); + assert.throws( + () => readExpectedSelectiveCoverageProvenance(provenancePath), + /Could not read M1 selective-coverage trust anchor/u, + ); +}); diff --git a/devnet/rfc64-m1-selective-coverage/operator-input.ts b/devnet/rfc64-m1-selective-coverage/operator-input.ts new file mode 100644 index 0000000000..00a6b75f6d --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/operator-input.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; + +import { + decodeExpectedSelectiveCoverageProvenance, + decodeSelectiveCoverageCorpus, +} from './evidence-codec.ts'; +import type { + ExpectedSelectiveCoverageProvenanceV1, + SelectiveCoverageCorpusV1, +} from './manifest.ts'; + +export function readSelectiveCoverageCorpus( + path: string, +): SelectiveCoverageCorpusV1 { + return readDecodedJson( + path, + 'M1 selective-coverage corpus', + decodeSelectiveCoverageCorpus, + ); +} + +export function readExpectedSelectiveCoverageProvenance( + path: string, +): ExpectedSelectiveCoverageProvenanceV1 { + return readDecodedJson( + path, + 'M1 selective-coverage trust anchor', + decodeExpectedSelectiveCoverageProvenance, + ); +} + +function readDecodedJson( + path: string, + label: string, + decoder: (input: unknown) => T | undefined, +): T { + let input: unknown; + try { + input = JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + throw new Error(`Could not read ${label}: ${path}`, { cause: error }); + } + const decoded = decoder(input); + if (decoded === undefined) { + throw new TypeError(`${label} failed closed-schema validation: ${path}`); + } + return decoded; +} diff --git a/devnet/rfc64-m1-selective-coverage/package.json b/devnet/rfc64-m1-selective-coverage/package.json new file mode 100644 index 0000000000..04dccb7040 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/package.json @@ -0,0 +1,13 @@ +{ + "name": "@devnet/rfc64-m1-selective-coverage", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Fail-closed evidence contract for RFC-64 M1 Edge selection and bounded Core public coverage.", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "node --experimental-strip-types --test verifier.test.ts runtime.test.ts process-runtime.test.ts adapter-environment.test.ts operator-input.test.ts boundary-codec.test.ts", + "live": "node --import tsx launch-live.ts", + "verify-live": "node --import tsx verify-live.ts" + } +} diff --git a/devnet/rfc64-m1-selective-coverage/process-runtime-fixture.mjs b/devnet/rfc64-m1-selective-coverage/process-runtime-fixture.mjs new file mode 100644 index 0000000000..3065bef34a --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/process-runtime-fixture.mjs @@ -0,0 +1,92 @@ +import { createInterface } from 'node:readline'; + +const commandSchema = 'dkg-rfc64-m1-selective-coverage-runtime-command-v1'; +const resultSchema = 'dkg-rfc64-m1-selective-coverage-runtime-result-v1'; +const protocol = 'dkg-rfc64-m1-selective-coverage-runtime-v1'; +const prefix = 'DKG_RFC64_M1_RESULT '; +const lines = createInterface({ input: process.stdin, crlfDelay: Infinity }); + +lines.on('line', (line) => { + const input = JSON.parse(line); + if (input.schema !== commandSchema || input.protocol !== protocol) process.exit(12); + let value = null; + if (input.command === 'start') { + if (Object.keys(input.payload).sort().join(',') !== 'role') process.exit(13); + const role = input.payload.role; + value = { + protocol, + role, + pid: process.pid, + peerId: `${role}-peer`, + networkId: process.env.FIXTURE_NETWORK_ID, + testedHeadCommit: process.env.FIXTURE_SOURCE_COMMIT, + runtimeManifestDigest: process.env.FIXTURE_RUNTIME_MANIFEST, + processStartedAt: 100, + processInstanceId: `${role}-instance`, + dataDirectoryIdentity: `${role}-data`, + evidenceWaveId: `${role}-wave`, + }; + } + const result = { + schema: resultSchema, + protocol, + sessionNonce: input.sessionNonce, + sequence: input.sequence, + ok: true, + value, + }; + const mode = process.env.FIXTURE_MODE; + if (input.command === process.env.FIXTURE_MALFORM_COMMAND) { + result.value = { unexpected: true }; + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`, () => process.exit(0)); + return; + } + if (input.command === 'publish-wave' && mode === 'malformed-publish') { + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`, () => process.exit(0)); + return; + } + if (input.command === 'shutdown' && mode === 'shutdown-nonzero') { + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`, () => process.exit(17)); + return; + } + if (input.command === 'shutdown' && mode === 'shutdown-hang') { + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`); + setInterval(() => undefined, 1_000); + return; + } + if (input.command === 'start' && mode && !mode.startsWith('shutdown-')) { + if (mode === 'malformed-publish') { + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`); + return; + } + if (mode === 'malformed-value') result.value = { role: input.payload.role }; + if (mode === 'wrong-nonce') result.sessionNonce = 'wrong-session'; + if (mode === 'wrong-protocol') result.protocol = 'wrong-protocol'; + if (mode === 'wrong-schema') result.schema = 'wrong-schema'; + if (mode === 'unknown-sequence') result.sequence += 1; + if (mode === 'extra-envelope') result.unexpected = true; + if (mode === 'mixed-success-envelope') result.error = 'contradictory failure'; + if (mode === 'mixed-failure-envelope') { + result.ok = false; + result.error = 'fixture failure'; + } + if (mode === 'nonboolean-ok') result.ok = 'yes'; + if (mode === 'failure-envelope') { + result.ok = false; + delete result.value; + result.error = 'fixture failure'; + } + if (mode === 'malformed-json') { + process.stdout.write(`${prefix}{not-json\n`, () => process.exit(0)); + return; + } + if (mode === 'oversized-line') { + process.stdout.write(`${'x'.repeat(1024 * 1024 + 1)}\n`, () => process.exit(0)); + return; + } + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`, () => process.exit(0)); + return; + } + process.stdout.write(`${prefix}${JSON.stringify(result)}\n`); + if (input.command === 'shutdown') process.exit(0); +}); diff --git a/devnet/rfc64-m1-selective-coverage/process-runtime.test.ts b/devnet/rfc64-m1-selective-coverage/process-runtime.test.ts new file mode 100644 index 0000000000..6ec313f42d --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/process-runtime.test.ts @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +import { + createSelectiveCoverageCorpus, + type ExpectedSelectiveCoverageProvenanceV1, +} from './manifest.ts'; +import { ProcessSelectiveCoverageRuntimeV1 } from './process-runtime.ts'; +import { SELECTIVE_COVERAGE_RUNTIME_PROTOCOL } from './runtime.ts'; + +test('exchanges sequence-bound JSON without sending the trust anchor to the adapter', async () => { + const runtime = new ProcessSelectiveCoverageRuntimeV1({ + command: process.execPath, + args: [resolve(import.meta.dirname, 'process-runtime-fixture.mjs')], + cwd: resolve(import.meta.dirname, '../..'), + timeoutMs: 5_000, + env: { + ...process.env, + FIXTURE_NETWORK_ID: 'otp:20430', + FIXTURE_SOURCE_COMMIT: 'a'.repeat(40), + FIXTURE_RUNTIME_MANIFEST: `sha256:${'b'.repeat(64)}`, + }, + }); + const corpus = createSelectiveCoverageCorpus({ + networkId: 'otp:20430', + coreAutomaticBatchSize: 1, + coreCoverageRoundLimit: 1, + graphs: [{ + contextGraphId: '0x1111111111111111111111111111111111111111/public', + accessPolicy: 0, + publishPolicy: 1, + edgePolicy: 'on-demand', + selectedSnapshot: { + vm: { headDigest: `sha256:${'1'.repeat(64)}`, inventoryDigest: `sha256:${'2'.repeat(64)}`, assetCount: 1, dataTripleCount: 1 }, + swm: { headDigest: `sha256:${'3'.repeat(64)}`, inventoryDigest: `sha256:${'4'.repeat(64)}`, assetCount: 1, dataTripleCount: 1 }, + }, + finalSnapshot: { + vm: { headDigest: `sha256:${'5'.repeat(64)}`, inventoryDigest: `sha256:${'6'.repeat(64)}`, assetCount: 2, dataTripleCount: 2 }, + swm: { headDigest: `sha256:${'7'.repeat(64)}`, inventoryDigest: `sha256:${'8'.repeat(64)}`, assetCount: 2, dataTripleCount: 2 }, + }, + }], + }); + const expected: ExpectedSelectiveCoverageProvenanceV1 = { + networkId: corpus.networkId, + testedHeadCommit: 'a'.repeat(40), + runtimeManifestDigest: `sha256:${'b'.repeat(64)}`, + corpusManifestDigest: corpus.manifestDigest, + publisherPeerId: 'publisher-peer', + edgePeerId: 'edge-peer', + corePeerId: 'core-peer', + }; + try { + const ready = await runtime.start('publisher'); + assert.equal(ready.protocol, SELECTIVE_COVERAGE_RUNTIME_PROTOCOL); + assert.equal(ready.role, 'publisher'); + assert.equal(ready.peerId, 'publisher-peer'); + assert.ok(ready.pid > 0); + await runtime.stop('publisher'); + } finally { + await runtime.close(); + } +}); + +for (const [mode, message] of [ + ['malformed-value', /response failed decoding/], + ['wrong-nonce', /invalid result envelope/], + ['wrong-protocol', /invalid result envelope/], + ['wrong-schema', /invalid result envelope/], + ['unknown-sequence', /unknown result sequence/], + ['extra-envelope', /invalid result envelope/], + ['mixed-success-envelope', /invalid result envelope/], + ['mixed-failure-envelope', /invalid result envelope/], + ['nonboolean-ok', /invalid result envelope/], + ['malformed-json', /malformed result JSON/], + ['oversized-line', /exceeds 1 MiB/], +] as const) { + test(`rejects fail-closed adapter output: ${mode}`, async () => { + const runtime = fixtureRuntime(mode); + try { + await assert.rejects(runtime.start('publisher'), message); + } finally { + await runtime.close().catch(() => undefined); + } + }); +} + +test('accepts the exact closed failure result envelope', async () => { + const runtime = fixtureRuntime('failure-envelope'); + try { + await assert.rejects(runtime.start('publisher'), /fixture failure/); + } finally { + await runtime.close().catch(() => undefined); + } +}); + +test('decodes non-start command results at the adapter boundary', async () => { + const runtime = fixtureRuntime('malformed-publish'); + try { + await runtime.start('publisher'); + await assert.rejects( + runtime.publishWave('selected'), + /response failed decoding: publish-wave/, + ); + } finally { + await runtime.close().catch(() => undefined); + } +}); + +test('missing adapter executable fails startup and close without hanging', async () => { + const runtime = new ProcessSelectiveCoverageRuntimeV1({ + command: resolve(import.meta.dirname, `missing-adapter-${process.pid}`), + cwd: resolve(import.meta.dirname, '../..'), + timeoutMs: 5_000, + }); + await assert.rejects(runtime.start('publisher'), /runtime adapter process failed/); + + let timeout: ReturnType | undefined; + const closeTimeout = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error('runtime close timed out')), 1_000); + }); + try { + await assert.rejects( + Promise.race([runtime.close(), closeTimeout]), + /runtime adapter process failed/, + ); + } finally { + if (timeout) clearTimeout(timeout); + } +}); + +test('a non-terminal child error cannot satisfy the process-exit proof', async () => { + const runtime = fixtureRuntime(); + const internals = runtime as unknown as { + readonly child: { emit(event: 'error', error: Error): boolean }; + readonly exited: Promise; + }; + try { + await runtime.start('publisher'); + internals.child.emit('error', new Error('synthetic live-child kill failure')); + assert.doesNotThrow(() => { + internals.child.emit('error', new Error('second synthetic live-child error')); + }); + const terminalState = await Promise.race([ + internals.exited.then(() => 'exited' as const), + new Promise<'still-running'>((resolveState) => { + setTimeout(() => resolveState('still-running'), 50); + }), + ]); + assert.equal(terminalState, 'still-running'); + } finally { + await runtime.close().catch(() => undefined); + } +}); + +test('rejects a non-zero adapter exit after shutdown acknowledgement', async () => { + const runtime = fixtureRuntime('shutdown-nonzero'); + await runtime.start('publisher'); + await assert.rejects( + runtime.close(), + /runtime adapter exited abnormally after shutdown \(code=17 signal=null\)/, + ); +}); + +test('rejects forced termination after shutdown acknowledgement', async () => { + const runtime = fixtureRuntime('shutdown-hang'); + await runtime.start('publisher'); + await assert.rejects( + runtime.close(), + /runtime adapter required forced SIGTERM during shutdown/, + ); +}); + +for (const [command, invoke] of [ + ['observe-edge', (runtime: ProcessSelectiveCoverageRuntimeV1) => + runtime.observeEdge('before-selection')], + ['synchronize-edge', (runtime: ProcessSelectiveCoverageRuntimeV1) => + runtime.synchronizeEdge({ + contextGraphId: '0x1111111111111111111111111111111111111111/public', + phase: 'selection', + syncMode: 'on-demand', + wave: 'selected', + })], + ['restart-edge', (runtime: ProcessSelectiveCoverageRuntimeV1) => runtime.restartEdge()], + ['wait-edge-reconciler', (runtime: ProcessSelectiveCoverageRuntimeV1) => + runtime.waitForEdgeReconciler({ + contextGraphId: '0x1111111111111111111111111111111111111111/public', + })], + ['core-automatic-round', (runtime: ProcessSelectiveCoverageRuntimeV1) => + runtime.runCoreAutomaticRound(0)], + ['observe-core-final', (runtime: ProcessSelectiveCoverageRuntimeV1) => + runtime.observeCoreFinal()], + ['shutdown', (runtime: ProcessSelectiveCoverageRuntimeV1) => runtime.close()], +] as const) { + test(`rejects malformed ${command} values at the adapter boundary`, async () => { + const runtime = fixtureRuntime(undefined, command); + try { + await runtime.start('publisher'); + await assert.rejects(invoke(runtime), /response failed decoding/); + } finally { + await runtime.close().catch(() => undefined); + } + }); +} + +function fixtureRuntime( + mode?: string, + malformedCommand?: string, +): ProcessSelectiveCoverageRuntimeV1 { + return new ProcessSelectiveCoverageRuntimeV1({ + command: process.execPath, + args: [resolve(import.meta.dirname, 'process-runtime-fixture.mjs')], + cwd: resolve(import.meta.dirname, '../..'), + timeoutMs: 5_000, + env: { + ...process.env, + ...(mode ? { FIXTURE_MODE: mode } : {}), + ...(malformedCommand ? { FIXTURE_MALFORM_COMMAND: malformedCommand } : {}), + FIXTURE_NETWORK_ID: 'otp:20430', + FIXTURE_SOURCE_COMMIT: 'a'.repeat(40), + FIXTURE_RUNTIME_MANIFEST: `sha256:${'b'.repeat(64)}`, + }, + }); +} diff --git a/devnet/rfc64-m1-selective-coverage/process-runtime.ts b/devnet/rfc64-m1-selective-coverage/process-runtime.ts new file mode 100644 index 0000000000..7c50bdb662 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/process-runtime.ts @@ -0,0 +1,394 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; + +import { + closedRecord, + defineRecordKeys, + plainRecord, +} from './boundary-codec.ts'; +import { + SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + type SelectiveCoverageEdgeRestartReceiptV1, + type SelectiveCoverageRuntimeReadyV1, + type SelectiveCoverageRuntimeRole, + type SelectiveCoverageRuntimeV1, +} from './runtime.ts'; +import { + type CoreAutomaticRoundV1, + type CoreFinalObservationV1, + type EdgeGraphObservationV1, + type EdgeSyncOperationV1, + type GraphObservationV1, +} from './manifest.ts'; +import type { SyncCoverageJournalReferenceV1 } from './sync-coverage-journal.ts'; +import { + decodeCoreFinalObservations, + decodeCoreRoundResult, + decodeEdgeObservations, + decodeEdgeReconcilerResult, + decodeEdgeSyncResult, + decodeGraphObservations, + decodeNull, + decodeRestartReceipt, + decodeRuntimeReady, +} from './runtime-wire.ts'; + +export const SELECTIVE_COVERAGE_RUNTIME_COMMAND_SCHEMA = + 'dkg-rfc64-m1-selective-coverage-runtime-command-v1' as const; +export const SELECTIVE_COVERAGE_RUNTIME_RESULT_SCHEMA = + 'dkg-rfc64-m1-selective-coverage-runtime-result-v1' as const; +export const SELECTIVE_COVERAGE_RUNTIME_RESULT_PREFIX = 'DKG_RFC64_M1_RESULT '; +const MAX_RESULT_LINE_BYTES = 1024 * 1024; +const CLOSE_GRACE_MS = 5_000; + +interface RuntimeSuccessResultEnvelopeV1 { + readonly schema: typeof SELECTIVE_COVERAGE_RUNTIME_RESULT_SCHEMA; + readonly protocol: typeof SELECTIVE_COVERAGE_RUNTIME_PROTOCOL; + readonly sessionNonce: string; + readonly sequence: number; + readonly ok: true; + readonly value: unknown; +} + +interface RuntimeFailureResultEnvelopeV1 { + readonly schema: typeof SELECTIVE_COVERAGE_RUNTIME_RESULT_SCHEMA; + readonly protocol: typeof SELECTIVE_COVERAGE_RUNTIME_PROTOCOL; + readonly sessionNonce: string; + readonly sequence: number; + readonly ok: false; + readonly error: string; +} + +const RUNTIME_SUCCESS_RESULT_KEYS = defineRecordKeys()( + 'schema', + 'protocol', + 'sessionNonce', + 'sequence', + 'ok', + 'value', +); +const RUNTIME_FAILURE_RESULT_KEYS = defineRecordKeys()( + 'schema', + 'protocol', + 'sessionNonce', + 'sequence', + 'ok', + 'error', +); + +interface PendingRequest { + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; +} + +interface ProcessExitOutcome { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + +/** + * JSON-lines bridge to an operator-owned adapter that controls three real DKG + * processes. Ordinary adapter logs may use stdout; only prefixed result lines + * are parsed as evidence-bearing responses. + */ +export class ProcessSelectiveCoverageRuntimeV1 implements SelectiveCoverageRuntimeV1 { + private readonly child: ChildProcessWithoutNullStreams; + private readonly pending = new Map(); + private sequence = 0; + private closed = false; + private closing = false; + private exitError: Error | undefined; + private stdoutBuffer = Buffer.alloc(0); + private readonly sessionNonce = randomBytes(32).toString('hex'); + private readonly exited: Promise; + private exitOutcome: ProcessExitOutcome | undefined; + + constructor(input: { + readonly command: string; + readonly args?: readonly string[]; + readonly cwd: string; + readonly env?: NodeJS.ProcessEnv; + readonly timeoutMs?: number; + }) { + if (!input.command.trim()) throw new TypeError('M1 runtime adapter command is empty'); + const timeoutMs = input.timeoutMs ?? 120_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 3_600_000) { + throw new RangeError('M1 runtime adapter timeout is outside 1s..1h'); + } + this.timeoutMs = timeoutMs; + this.child = spawn(input.command, [...(input.args ?? [])], { + cwd: input.cwd, + env: input.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.child.stderr.pipe(process.stderr); + this.child.stdout.on('data', (chunk: Buffer) => this.consumeChunk(chunk)); + let resolveExited!: (outcome: ProcessExitOutcome) => void; + this.exited = new Promise((resolveExit) => { + resolveExited = resolveExit; + }); + let terminal = false; + const resolveTerminal = (outcome: ProcessExitOutcome) => { + if (terminal) return; + terminal = true; + this.exitOutcome = outcome; + resolveExited(outcome); + }; + this.child.on('error', (error) => { + // Spawn failures are terminal but ChildProcess also emits `error` for + // failures such as an unsuccessful kill while the child is still live. + // Do not let those later errors falsely satisfy cleanup. + if (this.child.pid === undefined + || this.child.exitCode !== null + || this.child.signalCode !== null) { + resolveTerminal({ code: this.child.exitCode, signal: this.child.signalCode }); + } + this.failAll(new Error('M1 runtime adapter process failed', { cause: error })); + }); + this.child.once('exit', (code, signal) => { + resolveTerminal({ code, signal }); + if (!this.closing || this.pending.size > 0) { + this.failAll(new Error( + `M1 runtime adapter exited before shutdown acknowledgement ` + + `(code=${String(code)} signal=${String(signal)})`, + )); + } + }); + this.child.once('close', (code, signal) => { + resolveTerminal({ code, signal }); + if (!this.closing || this.pending.size > 0) { + this.failAll(new Error( + `M1 runtime adapter closed before shutdown acknowledgement ` + + `(code=${String(code)} signal=${String(signal)})`, + )); + } + }); + } + + private readonly timeoutMs: number; + + async start(role: SelectiveCoverageRuntimeRole): Promise { + return await this.request('start', { role }, decodeRuntimeReady); + } + + async stop(role: SelectiveCoverageRuntimeRole): Promise { + await this.request('stop', { role }, decodeNull); + } + + async publishWave(wave: 'selected' | 'final'): Promise { + return await this.request('publish-wave', { wave }, decodeGraphObservations); + } + + async observeEdge( + checkpoint: 'before-selection' | 'after-selection' | 'after-restart' + | 'after-second-on-demand', + ): Promise { + return await this.request('observe-edge', { checkpoint }, decodeEdgeObservations); + } + + async synchronizeEdge(input: { + readonly contextGraphId: string; + readonly phase: 'selection' | 'post-restart-explicit'; + readonly syncMode: 'always-on' | 'on-demand'; + readonly wave: EdgeSyncOperationV1['completedWave']; + }): Promise<{ + readonly operation: Omit; + readonly journal?: SyncCoverageJournalReferenceV1; + }> { + return await this.request('synchronize-edge', input, decodeEdgeSyncResult); + } + + async restartEdge(): Promise { + return await this.request('restart-edge', {}, decodeRestartReceipt); + } + + async waitForEdgeReconciler(input: { + readonly contextGraphId: string; + }): Promise<{ + readonly operation: Omit; + readonly journal: SyncCoverageJournalReferenceV1; + }> { + return await this.request('wait-edge-reconciler', input, decodeEdgeReconcilerResult); + } + + async runCoreAutomaticRound(round: number): Promise<{ + readonly round: CoreAutomaticRoundV1; + readonly journal: SyncCoverageJournalReferenceV1; + }> { + return await this.request('core-automatic-round', { round }, decodeCoreRoundResult); + } + + async observeCoreFinal(): Promise { + return await this.request('observe-core-final', {}, decodeCoreFinalObservations); + } + + async close(): Promise { + if (this.closed) return; + this.closing = true; + let shutdownFailure: unknown; + try { + await this.request('shutdown', {}, decodeNull); + } catch (error) { + shutdownFailure = error; + } + this.closed = true; + this.child.stdin.end(); + let forcedSignal: NodeJS.Signals | undefined; + let exit = await this.waitForExit(CLOSE_GRACE_MS); + if (!exit) { + forcedSignal = 'SIGTERM'; + this.child.kill('SIGTERM'); + exit = await this.waitForExit(CLOSE_GRACE_MS); + if (!exit) { + forcedSignal = 'SIGKILL'; + this.child.kill('SIGKILL'); + exit = await this.waitForExit(CLOSE_GRACE_MS); + } + } + if (shutdownFailure !== undefined) throw shutdownFailure; + if (this.exitError) throw this.exitError; + if (!exit) { + throw new Error('M1 runtime adapter did not exit after forced SIGKILL'); + } + if (forcedSignal) { + throw new Error(`M1 runtime adapter required forced ${forcedSignal} during shutdown`); + } + if (exit.code !== 0 || exit.signal !== null) { + throw new Error( + `M1 runtime adapter exited abnormally after shutdown ` + + `(code=${String(exit.code)} signal=${String(exit.signal)})`, + ); + } + } + + private request( + command: string, + payload: unknown, + decode: (input: unknown) => T, + ): Promise { + if (this.closed) return Promise.reject(new Error('M1 runtime adapter is closed')); + if (this.exitError) return Promise.reject(this.exitError); + const sequence = this.sequence; + this.sequence += 1; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(sequence); + reject(new Error(`M1 runtime adapter command timed out: ${command}`)); + }, this.timeoutMs); + timer.unref(); + this.pending.set(sequence, { + resolve: (value) => { + try { + resolve(decode(value)); + } catch (error) { + reject(new Error(`M1 runtime adapter response failed decoding: ${command}`, { + cause: error, + })); + } + }, + reject, + timer, + }); + const envelope = JSON.stringify({ + schema: SELECTIVE_COVERAGE_RUNTIME_COMMAND_SCHEMA, + protocol: SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + sessionNonce: this.sessionNonce, + sequence, + command, + payload, + }); + this.child.stdin.write(`${envelope}\n`, (error) => { + if (!error) return; + const request = this.pending.get(sequence); + if (!request) return; + clearTimeout(request.timer); + this.pending.delete(sequence); + request.reject(new Error(`M1 runtime adapter command write failed: ${command}`, { + cause: error, + })); + }); + }); + } + + private consumeChunk(chunk: Buffer): void { + this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]); + let newline = this.stdoutBuffer.indexOf(0x0a); + while (newline >= 0) { + const line = this.stdoutBuffer.subarray(0, newline); + this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1); + if (line.byteLength > MAX_RESULT_LINE_BYTES) { + this.failAll(new Error('M1 runtime adapter result/log line exceeds 1 MiB')); + return; + } + this.consumeLine(line.toString('utf8').replace(/\r$/u, '')); + newline = this.stdoutBuffer.indexOf(0x0a); + } + if (this.stdoutBuffer.byteLength > MAX_RESULT_LINE_BYTES) { + this.failAll(new Error('M1 runtime adapter result/log line exceeds 1 MiB')); + } + } + + private consumeLine(line: string): void { + if (!line.startsWith(SELECTIVE_COVERAGE_RUNTIME_RESULT_PREFIX)) return; + let value: unknown; + try { + value = JSON.parse(line.slice(SELECTIVE_COVERAGE_RUNTIME_RESULT_PREFIX.length)); + } catch (error) { + this.failAll(new Error('M1 runtime adapter emitted malformed result JSON', { cause: error })); + return; + } + const probe = plainRecord(value); + const row = probe?.['ok'] === true + ? closedRecord(value, RUNTIME_SUCCESS_RESULT_KEYS) + : probe?.['ok'] === false + ? closedRecord(value, RUNTIME_FAILURE_RESULT_KEYS) + : undefined; + if (!row + || row['schema'] !== SELECTIVE_COVERAGE_RUNTIME_RESULT_SCHEMA + || row['protocol'] !== SELECTIVE_COVERAGE_RUNTIME_PROTOCOL + || row['sessionNonce'] !== this.sessionNonce + || !Number.isSafeInteger(row['sequence']) + || (row['ok'] === false && (typeof row['error'] !== 'string' || !row['error']))) { + this.failAll(new Error('M1 runtime adapter emitted an invalid result envelope')); + return; + } + const sequence = row['sequence'] as number; + const request = this.pending.get(sequence); + if (!request) { + this.failAll(new Error(`M1 runtime adapter emitted an unknown result sequence: ${sequence}`)); + return; + } + clearTimeout(request.timer); + this.pending.delete(sequence); + if (row['ok'] === true) { + request.resolve(row['value']); + return; + } + request.reject(new Error(row['error'] as string)); + } + + private failAll(error: Error): void { + if (!this.exitError) this.exitError = error; + for (const request of this.pending.values()) { + clearTimeout(request.timer); + request.reject(error); + } + this.pending.clear(); + } + + private async waitForExit(timeoutMs: number): Promise { + if (this.exitOutcome) return this.exitOutcome; + if (this.child.exitCode !== null || this.child.signalCode !== null) { + return { code: this.child.exitCode, signal: this.child.signalCode }; + } + let timer: ReturnType | undefined; + const timedOut = new Promise((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout(undefined), timeoutMs); + timer.unref(); + }); + const result = await Promise.race([this.exited, timedOut]); + if (timer) clearTimeout(timer); + return result; + } +} diff --git a/devnet/rfc64-m1-selective-coverage/runtime-wire.ts b/devnet/rfc64-m1-selective-coverage/runtime-wire.ts new file mode 100644 index 0000000000..33805862d5 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/runtime-wire.ts @@ -0,0 +1,199 @@ +import { + type CoreAutomaticRoundV1, + type CoreFinalObservationV1, + type EdgeGraphObservationV1, + type EdgeSyncOperationV1, + type GraphObservationV1, +} from './manifest.ts'; +import { + decodeCoreAutomaticRound, + decodeCoreFinalObservations as parseCoreFinalObservations, + decodeEdgeObservations as parseEdgeObservations, + decodeEdgeSyncOperationPayload, + decodeGraphObservations as parseGraphObservations, +} from './evidence-codec.ts'; +import { + SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + type SelectiveCoverageEdgeRestartReceiptV1, + type SelectiveCoverageRuntimeReadyV1, +} from './runtime.ts'; +import { + parseSyncCoverageJournalReferenceV1, + type SyncCoverageJournalReferenceV1, +} from './sync-coverage-journal.ts'; +import { + boundedString, + closedRecord, + defineRecordKeys, + nonNegativeInteger, + positiveInteger, + requireDecoded, +} from './boundary-codec.ts'; + +type EdgeRestartPreviousV1 = SelectiveCoverageEdgeRestartReceiptV1['previous']; +type EdgeSyncResultV1 = { + readonly operation: Omit; + readonly journal?: SyncCoverageJournalReferenceV1; +}; +type EdgeReconcilerResultV1 = { + readonly operation: Omit; + readonly journal: SyncCoverageJournalReferenceV1; +}; +type CoreRoundResultV1 = { + readonly round: CoreAutomaticRoundV1; + readonly journal: SyncCoverageJournalReferenceV1; +}; +type OptionalPropertyKeys = { + [Key in keyof T]-?: object extends Pick ? Key : never; +}[keyof T]; + +const RUNTIME_READY_KEYS = defineRecordKeys()( + 'protocol', + 'role', + 'pid', + 'peerId', + 'networkId', + 'testedHeadCommit', + 'runtimeManifestDigest', + 'processStartedAt', + 'processInstanceId', + 'dataDirectoryIdentity', + 'evidenceWaveId', +); +const EDGE_RESTART_RECEIPT_KEYS = defineRecordKeys()( + 'previous', + 'current', +); +const EDGE_RESTART_PREVIOUS_KEYS = defineRecordKeys()( + 'pid', + 'processInstanceId', + 'exitedAt', +); +const EDGE_SYNC_RESULT_KEYS = defineRecordKeys()('operation', 'journal'); +const EDGE_SYNC_RESULT_OPTIONAL_KEYS = defineRecordKeys< + Pick> +>()('journal'); +const EDGE_RECONCILER_RESULT_KEYS = defineRecordKeys()( + 'operation', + 'journal', +); +const CORE_ROUND_RESULT_KEYS = defineRecordKeys()('round', 'journal'); + +export function decodeRuntimeReady(input: unknown): SelectiveCoverageRuntimeReadyV1 { + const row = record(input, RUNTIME_READY_KEYS); + const role = row.role; + if (row.protocol !== SELECTIVE_COVERAGE_RUNTIME_PROTOCOL + || (role !== 'publisher' && role !== 'edge' && role !== 'core') + || !positiveInteger(row.pid) + || !nonNegativeInteger(row.processStartedAt)) fail('runtime ready'); + return { + protocol: SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + role, + pid: row.pid as number, + peerId: text(row.peerId, 'peerId'), + networkId: text(row.networkId, 'networkId'), + testedHeadCommit: text(row.testedHeadCommit, 'testedHeadCommit'), + runtimeManifestDigest: text(row.runtimeManifestDigest, 'runtimeManifestDigest'), + processStartedAt: row.processStartedAt as number, + processInstanceId: text(row.processInstanceId, 'processInstanceId'), + dataDirectoryIdentity: text(row.dataDirectoryIdentity, 'dataDirectoryIdentity'), + evidenceWaveId: text(row.evidenceWaveId, 'evidenceWaveId'), + }; +} + +export function decodeRestartReceipt(input: unknown): SelectiveCoverageEdgeRestartReceiptV1 { + const row = record(input, EDGE_RESTART_RECEIPT_KEYS); + const previous = record(row.previous, EDGE_RESTART_PREVIOUS_KEYS); + if (!positiveInteger(previous.pid) || !nonNegativeInteger(previous.exitedAt)) { + fail('restart receipt'); + } + return { + previous: { + pid: previous.pid as number, + processInstanceId: text(previous.processInstanceId, 'processInstanceId'), + exitedAt: previous.exitedAt as number, + }, + current: decodeRuntimeReady(row.current), + }; +} + +export function decodeGraphObservations(input: unknown): readonly GraphObservationV1[] { + return requireDecoded(parseGraphObservations(input), 'M1 runtime adapter graph observations'); +} + +export function decodeEdgeObservations(input: unknown): readonly EdgeGraphObservationV1[] { + return requireDecoded(parseEdgeObservations(input), 'M1 runtime adapter Edge observations'); +} + +export function decodeCoreFinalObservations(input: unknown): readonly CoreFinalObservationV1[] { + return requireDecoded( + parseCoreFinalObservations(input), + 'M1 runtime adapter Core final observations', + ); +} + +export function decodeEdgeSyncResult(input: unknown): EdgeSyncResultV1 { + const row = optionalRecord(input, EDGE_SYNC_RESULT_KEYS, EDGE_SYNC_RESULT_OPTIONAL_KEYS); + const journal = Object.hasOwn(row, 'journal') + ? requiredJournal(row.journal) + : undefined; + return { operation: decodeEdgeOperation(row.operation), ...(journal ? { journal } : {}) }; +} + +export function decodeEdgeReconcilerResult(input: unknown): EdgeReconcilerResultV1 { + const row = record(input, EDGE_RECONCILER_RESULT_KEYS); + return { + operation: decodeEdgeOperation(row.operation), + journal: requiredJournal(row.journal), + }; +} + +export function decodeCoreRoundResult(input: unknown): CoreRoundResultV1 { + const row = record(input, CORE_ROUND_RESULT_KEYS); + return { + round: requireDecoded(decodeCoreAutomaticRound(row.round), 'M1 runtime adapter Core round'), + journal: requiredJournal(row.journal), + }; +} + +export function decodeNull(input: unknown): null { + if (input !== null) fail('null acknowledgement'); + return null; +} + +function decodeEdgeOperation(input: unknown): Omit { + return requireDecoded( + decodeEdgeSyncOperationPayload(input), + 'M1 runtime adapter Edge operation', + ); +} + +function requiredJournal(input: unknown): SyncCoverageJournalReferenceV1 { + const parsed = parseSyncCoverageJournalReferenceV1(input); + if (!parsed) fail('journal reference'); + return parsed; +} + +function record(input: unknown, keys: readonly string[]): Record { + return requireDecoded(closedRecord(input, keys), 'M1 runtime adapter record'); +} + +function optionalRecord( + input: unknown, + keys: readonly string[], + optionalKeys: readonly string[], +): Record { + const optional = new Set(optionalKeys); + return requireDecoded( + closedRecord(input, keys.filter((key) => !optional.has(key)), optionalKeys), + 'M1 runtime adapter record', + ); +} + +function text(input: unknown, label: string): string { + return requireDecoded(boundedString(input, 1, 4_096), `M1 runtime adapter ${label}`); +} + +function fail(label: string): never { + throw new TypeError(`M1 runtime adapter returned invalid ${label}`); +} diff --git a/devnet/rfc64-m1-selective-coverage/runtime.test.ts b/devnet/rfc64-m1-selective-coverage/runtime.test.ts new file mode 100644 index 0000000000..79906b3595 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/runtime.test.ts @@ -0,0 +1,640 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + canonicalJson, + createSelectiveCoverageCorpus, + type CoreAutomaticRoundV1, + type CoreFinalObservationV1, + type EdgeGraphObservationV1, + type EdgeSyncOperationV1, + type ExpectedSelectiveCoverageProvenanceV1, + type GraphObservationV1, + type GraphSnapshotExpectationV1, + type SelectiveCoverageGraphV1, +} from './manifest.ts'; +import { + collectSelectiveCoverageEvidenceV1, + SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + type SelectiveCoverageEdgeRestartReceiptV1, + type SelectiveCoverageRuntimeReadyV1, + type SelectiveCoverageRuntimeRole, + type SelectiveCoverageRuntimeV1, +} from './runtime.ts'; +import { decodeEdgeSyncOperationPayload } from './evidence-codec.ts'; +import { decodeEdgeSyncResult } from './runtime-wire.ts'; +import type { SyncCoverageJournalReferenceV1 } from './sync-coverage-journal.ts'; +import { runSelectiveCoverageLiveV1 } from './live-runner.ts'; + +const digest = (value: string) => + `sha256:${createHash('sha256').update(value).digest('hex')}`; +const graphId = (name: string) => `0x1111111111111111111111111111111111111111/${name}`; + +function snapshot(name: string, wave: 'selected' | 'final'): GraphSnapshotExpectationV1 { + const count = wave === 'selected' ? 1 : 2; + return { + vm: { + headDigest: digest(`${name}:${wave}:vm:head`), + inventoryDigest: digest(`${name}:${wave}:vm:inventory`), + assetCount: count, + dataTripleCount: count * 20, + }, + swm: { + headDigest: digest(`${name}:${wave}:swm:head`), + inventoryDigest: digest(`${name}:${wave}:swm:inventory`), + assetCount: count, + dataTripleCount: count * 15, + }, + }; +} + +const graphInputs = [ + ['01-public-open-on-demand', 0, 1, 'on-demand'], + ['02-public-curated-always-on', 0, 0, 'always-on'], + ['03-public-open-unselected', 0, 1, 'unselected'], + ['04-private-open', 1, 1, 'unselected'], + ['05-private-curated', 1, 0, 'unselected'], +] as const; +const graphs: readonly SelectiveCoverageGraphV1[] = graphInputs.map((row) => ({ + contextGraphId: graphId(row[0]), + accessPolicy: row[1], + publishPolicy: row[2], + edgePolicy: row[3], + selectedSnapshot: snapshot(row[0], 'selected'), + finalSnapshot: snapshot(row[0], 'final'), +})); +const corpus = createSelectiveCoverageCorpus({ + networkId: 'otp:20430', + coreAutomaticBatchSize: 2, + coreCoverageRoundLimit: 2, + graphs, +}); +const expected: ExpectedSelectiveCoverageProvenanceV1 = { + networkId: corpus.networkId, + testedHeadCommit: 'a'.repeat(40), + runtimeManifestDigest: digest('runtime'), + corpusManifestDigest: corpus.manifestDigest, + publisherPeerId: 'publisher-peer', + edgePeerId: 'edge-peer', + corePeerId: 'core-peer', +}; + +function exactObservation( + graph: SelectiveCoverageGraphV1, + expectedSnapshot: GraphSnapshotExpectationV1, +): GraphObservationV1 { + const plane = (expectedPlane: GraphSnapshotExpectationV1['vm']) => ({ + reportedComplete: true, + headDigest: expectedPlane.headDigest, + inventoryDigest: expectedPlane.inventoryDigest, + assetCount: expectedPlane.assetCount, + metadataTripleCount: 4, + dataTripleCount: expectedPlane.dataTripleCount, + }); + return { + contextGraphId: graph.contextGraphId, + vm: plane(expectedSnapshot.vm), + swm: plane(expectedSnapshot.swm), + }; +} + +function absentObservation(contextGraphId: string): GraphObservationV1 { + const plane = { + reportedComplete: false, + headDigest: null, + inventoryDigest: null, + assetCount: 0, + metadataTripleCount: 0, + dataTripleCount: 0, + } as const; + return { contextGraphId, vm: { ...plane }, swm: { ...plane } }; +} + +test('keeps artifact and adapter Edge-operation boundaries in lockstep', () => { + const operation: Omit = { + phase: 'selection', + source: 'user', + syncMode: 'on-demand', + contextGraphId: graphs[0].contextGraphId, + jobId: 'edge-select-on-demand', + completedWave: 'selected', + completedSnapshot: graphs[0].selectedSnapshot, + }; + assert.deepEqual(decodeEdgeSyncOperationPayload(operation), operation); + assert.deepEqual(decodeEdgeSyncResult({ operation }).operation, operation); + + const unknownField = { ...operation, futureField: true }; + assert.equal(decodeEdgeSyncOperationPayload(unknownField), undefined); + assert.throws( + () => decodeEdgeSyncResult({ operation: unknownField }), + /Invalid M1 runtime adapter Edge operation/, + ); +}); + +class ScriptedRuntime implements SelectiveCoverageRuntimeV1 { + readonly calls: string[] = []; + readonly stopped: SelectiveCoverageRuntimeRole[] = []; + readyMutation?: (ready: SelectiveCoverageRuntimeReadyV1) => SelectiveCoverageRuntimeReadyV1; + selectedPublisherMutation?: (rows: GraphObservationV1[]) => GraphObservationV1[]; + operationMutation?: ( + operation: Omit, + ) => Omit; + coreRoundMutation?: (round: CoreAutomaticRoundV1) => CoreAutomaticRoundV1; + edgeJournalMutation?: ( + journal: SyncCoverageJournalReferenceV1, + ) => SyncCoverageJournalReferenceV1; + coreJournalMutation?: ( + journal: SyncCoverageJournalReferenceV1, + ) => SyncCoverageJournalReferenceV1; + restartReceiptMutation?: ( + receipt: SelectiveCoverageEdgeRestartReceiptV1, + ) => SelectiveCoverageEdgeRestartReceiptV1; + + async start(role: SelectiveCoverageRuntimeRole): Promise { + this.calls.push(`start:${role}`); + const pid = role === 'publisher' ? 101 : role === 'edge' ? 102 : 104; + const peerId = role === 'publisher' + ? expected.publisherPeerId + : role === 'edge' + ? expected.edgePeerId + : expected.corePeerId; + const ready: SelectiveCoverageRuntimeReadyV1 = { + protocol: SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + role, + pid, + peerId, + networkId: expected.networkId, + testedHeadCommit: expected.testedHeadCommit, + runtimeManifestDigest: expected.runtimeManifestDigest, + processStartedAt: role === 'edge' ? 0 : 1, + processInstanceId: `${role}-instance-before`, + dataDirectoryIdentity: `${role}-data`, + evidenceWaveId: role === 'core' ? 'core-wave' : `${role}-wave-before`, + }; + return this.readyMutation?.(ready) ?? ready; + } + + async stop(role: SelectiveCoverageRuntimeRole): Promise { + this.calls.push(`stop:${role}`); + this.stopped.push(role); + } + + async publishWave(wave: 'selected' | 'final'): Promise { + this.calls.push(`publish:${wave}`); + const rows = corpus.graphs.map((graph) => + exactObservation(graph, wave === 'selected' ? graph.selectedSnapshot : graph.finalSnapshot)); + return wave === 'selected' ? this.selectedPublisherMutation?.(rows) ?? rows : rows; + } + + async observeEdge( + checkpoint: 'before-selection' | 'after-selection' | 'after-restart' + | 'after-second-on-demand', + ): Promise { + this.calls.push(`observe-edge:${checkpoint}`); + return corpus.graphs.map((graph): EdgeGraphObservationV1 => { + if (checkpoint === 'before-selection' + || graph.accessPolicy !== 0 + || graph.edgePolicy === 'unselected') { + return { + ...absentObservation(graph.contextGraphId), + runtimeSyncMode: null, + producingJobId: null, + }; + } + const alwaysOn = graph.edgePolicy === 'always-on'; + const afterFinal = checkpoint === 'after-second-on-demand' + || (checkpoint === 'after-restart' && alwaysOn); + const selectionJob = alwaysOn ? 'edge-select-always-on' : 'edge-select-on-demand'; + const producingJobId = afterFinal + ? (alwaysOn ? 'edge-auto-always-on' : 'edge-second-on-demand') + : selectionJob; + return { + ...exactObservation( + graph, + afterFinal ? graph.finalSnapshot : graph.selectedSnapshot, + ), + runtimeSyncMode: checkpoint === 'after-restart' && !alwaysOn + ? null + : graph.edgePolicy, + producingJobId, + }; + }); + } + + async synchronizeEdge(input: { + readonly contextGraphId: string; + readonly phase: 'selection' | 'post-restart-explicit'; + readonly syncMode: 'always-on' | 'on-demand'; + readonly wave: EdgeSyncOperationV1['completedWave']; + }): Promise<{ + readonly operation: Omit; + readonly journal?: SyncCoverageJournalReferenceV1; + }> { + this.calls.push(`edge-sync:${input.phase}:${input.contextGraphId}`); + const graph = corpus.graphs.find((candidate) => + candidate.contextGraphId === input.contextGraphId)!; + const jobId = input.phase === 'post-restart-explicit' + ? 'edge-second-on-demand' + : input.syncMode === 'always-on' + ? 'edge-select-always-on' + : 'edge-select-on-demand'; + const operation: Omit = { + phase: input.phase, + source: 'user', + syncMode: input.syncMode, + contextGraphId: input.contextGraphId, + jobId, + completedWave: input.wave, + completedSnapshot: input.wave === 'selected' + ? graph.selectedSnapshot + : graph.finalSnapshot, + }; + const result = this.operationMutation?.(operation) ?? operation; + return { operation: result }; + } + + async restartEdge(): Promise { + this.calls.push('restart:edge'); + const ready: SelectiveCoverageRuntimeReadyV1 = { + protocol: SELECTIVE_COVERAGE_RUNTIME_PROTOCOL, + role: 'edge', + pid: 103, + peerId: expected.edgePeerId, + networkId: expected.networkId, + testedHeadCommit: expected.testedHeadCommit, + runtimeManifestDigest: expected.runtimeManifestDigest, + processStartedAt: 1, + processInstanceId: 'edge-instance-after', + dataDirectoryIdentity: 'edge-data', + evidenceWaveId: 'edge-wave', + }; + const receipt: SelectiveCoverageEdgeRestartReceiptV1 = { + previous: { + pid: 102, + processInstanceId: 'edge-instance-before', + exitedAt: 1, + }, + current: this.readyMutation?.(ready) ?? ready, + }; + return this.restartReceiptMutation?.(receipt) ?? receipt; + } + + async waitForEdgeReconciler(input: { + readonly contextGraphId: string; + }): Promise<{ + readonly operation: Omit; + readonly journal: SyncCoverageJournalReferenceV1; + }> { + this.calls.push(`edge-sync:post-restart-auto:${input.contextGraphId}`); + const graph = corpus.graphs.find((candidate) => + candidate.contextGraphId === input.contextGraphId)!; + const base: Omit = { + phase: 'post-restart-auto', + source: 'reconciler', + syncMode: 'always-on', + contextGraphId: input.contextGraphId, + jobId: 'edge-auto-always-on', + completedWave: 'final', + completedSnapshot: graph.finalSnapshot, + }; + const operation = this.operationMutation?.(base) ?? base; + const journal = journalReference({ + kind: 'edge-reconciler-job', + sequence: 1, + waveId: 'edge-wave', + jobId: operation.jobId, + contextGraphId: operation.contextGraphId, + source: 'reconciler', + trigger: 'periodic-reconciler', + syncMode: 'always-on', + rehydratedSelectionCount: 1, + evidenceTruncated: false, + state: 'complete', + verified: { metadata: true, durable: true, sharedMemory: true }, + startedAt: 10, + finishedAt: 11, + }); + return { + operation, + journal: this.edgeJournalMutation?.(journal) ?? journal, + }; + } + + async runCoreAutomaticRound(round: number): Promise<{ + readonly round: CoreAutomaticRoundV1; + readonly journal: SyncCoverageJournalReferenceV1; + }> { + this.calls.push(`core-round:${round}`); + const publicGraphs = corpus.graphs.filter((graph) => graph.accessPolicy === 0); + const selected = round === 0 ? publicGraphs.slice(0, 2) : publicGraphs.slice(2); + const result: CoreAutomaticRoundV1 = { + round, + jobId: `core-auto-${round}`, + planningLane: expected.publisherPeerId, + source: 'automatic-core-public', + configuredBatchSize: corpus.coreAutomaticBatchSize, + explicitSelectedContextGraphIds: [], + contextGraphIds: selected.map((graph) => graph.contextGraphId), + completions: selected.map((graph) => ({ + contextGraphId: graph.contextGraphId, + completedWave: 'final', + completedSnapshot: graph.finalSnapshot, + })), + }; + const observed = this.coreRoundMutation?.(result) ?? result; + return { + round: observed, + journal: this.coreJournalMutation?.(journalReference({ + kind: 'core-automatic-round', + sequence: round + 1, + waveId: 'core-wave', + jobId: observed.jobId, + planningLane: observed.planningLane, + source: 'automatic-core-public', + trigger: 'connection-open', + configuredBatchSize: observed.configuredBatchSize, + effectiveBatchSize: observed.configuredBatchSize, + explicitSelectedContextGraphIds: observed.explicitSelectedContextGraphIds, + explicitSelectedContextGraphCount: observed.explicitSelectedContextGraphIds.length, + automaticContextGraphIds: observed.contextGraphIds, + automaticContextGraphCount: observed.contextGraphIds.length, + evidenceTruncated: false, + state: 'complete', + startedAt: 20 + round, + finishedAt: 21 + round, + completions: observed.completions.map((completion) => ({ + jobId: observed.jobId, + contextGraphId: completion.contextGraphId, + state: 'complete', + verified: { metadata: true, durable: true, sharedMemory: true }, + finishedAt: 21 + round, + })), + })) ?? journalReference({ + kind: 'core-automatic-round', + sequence: round + 1, + waveId: 'core-wave', + jobId: observed.jobId, + planningLane: observed.planningLane, + source: 'automatic-core-public', + trigger: 'connection-open', + configuredBatchSize: observed.configuredBatchSize, + effectiveBatchSize: observed.configuredBatchSize, + explicitSelectedContextGraphIds: observed.explicitSelectedContextGraphIds, + explicitSelectedContextGraphCount: observed.explicitSelectedContextGraphIds.length, + automaticContextGraphIds: observed.contextGraphIds, + automaticContextGraphCount: observed.contextGraphIds.length, + evidenceTruncated: false, + state: 'complete', + startedAt: 20 + round, + finishedAt: 21 + round, + completions: observed.completions.map((completion) => ({ + jobId: observed.jobId, + contextGraphId: completion.contextGraphId, + state: 'complete', + verified: { metadata: true, durable: true, sharedMemory: true }, + finishedAt: 21 + round, + })), + }), + }; + } + + async observeCoreFinal(): Promise { + this.calls.push('observe-core-final'); + return corpus.graphs.map((graph, index) => ({ + ...(graph.accessPolicy === 0 + ? exactObservation(graph, graph.finalSnapshot) + : absentObservation(graph.contextGraphId)), + automaticJobIds: graph.accessPolicy === 0 + ? [index < 2 ? 'core-auto-0' : 'core-auto-1'] + : [], + })); + } +} + +function journalReference( + entry: Record, +): SyncCoverageJournalReferenceV1 { + const sequence = entry['sequence'] as number; + return { + sequence, + snapshot: { + schemaVersion: 1, + processStartedAt: 1, + waveId: entry['waveId'], + capacity: 256, + nextSequence: sequence + 1, + droppedBeforeSequence: 0, + entries: [entry], + }, + }; +} + +test('collects the anchored three-process Edge/Core sequence and cleans up', async () => { + const runtime = new ScriptedRuntime(); + const evidence = await collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }); + + assert.equal(evidence.provenance.edgePeerId, expected.edgePeerId); + assert.doesNotThrow(() => canonicalJson(evidence)); + assert.deepEqual(evidence.core.rounds.map((round) => round.contextGraphIds.length), [2, 1]); + assert.deepEqual( + evidence.edge.operations.map((operation) => [ + operation.sequence, + operation.phase, + operation.syncMode, + operation.contextGraphId, + ]), + [ + [0, 'selection', 'on-demand', graphs[0]!.contextGraphId], + [1, 'selection', 'always-on', graphs[1]!.contextGraphId], + [2, 'post-restart-auto', 'always-on', graphs[1]!.contextGraphId], + [3, 'post-restart-explicit', 'on-demand', graphs[0]!.contextGraphId], + ], + ); + assert.deepEqual(runtime.stopped, ['core', 'edge', 'publisher']); + assert.ok( + runtime.calls.indexOf('publish:final') < runtime.calls.indexOf('start:core'), + 'Core must join cold after final publication', + ); + assert.ok( + runtime.calls.indexOf('observe-edge:after-restart') + < runtime.calls.findIndex((call) => call.startsWith('edge-sync:post-restart-explicit')), + 'on-demand state must be observed stale before the second user request', + ); +}); + +test('rejects metadata-only runtime output and does not skip cleanup', async () => { + const runtime = new ScriptedRuntime(); + runtime.selectedPublisherMutation = (rows) => { + rows[0] = { + ...rows[0]!, + vm: { + ...rows[0]!.vm, + assetCount: 0, + dataTripleCount: 0, + }, + }; + return rows; + }; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /failed closed/, + ); + assert.deepEqual(runtime.stopped, ['core', 'edge', 'publisher']); +}); + +test('rejects a runtime identity that is not externally anchored', async () => { + const runtime = new ScriptedRuntime(); + runtime.readyMutation = (ready) => ready.role === 'edge' + ? { ...ready, peerId: 'unexpected-edge' } + : ready; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /edge runtime identity differs/, + ); + assert.deepEqual(runtime.stopped, ['edge', 'publisher']); + assert.equal(runtime.calls.includes('publish:selected'), false); +}); + +test('rejects relabelled Edge reconciler work', async () => { + const runtime = new ScriptedRuntime(); + runtime.operationMutation = (operation) => operation.phase === 'post-restart-auto' + ? { ...operation, source: 'user' } + : operation; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /failed closed/, + ); +}); + +test('rejects truncated automatic Edge journal evidence', async () => { + const runtime = new ScriptedRuntime(); + runtime.edgeJournalMutation = (journal) => { + const copy = structuredClone(journal) as any; + copy.snapshot.entries[0].evidenceTruncated = true; + return copy; + }; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /missing, truncated, or incomplete/, + ); +}); + +test('rejects Core work that is not an automatic scheduler round', async () => { + const runtime = new ScriptedRuntime(); + runtime.coreRoundMutation = (round) => ({ + ...round, + explicitSelectedContextGraphIds: [graphs[0]!.contextGraphId], + }); + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /not scheduler-issued automatic coverage/, + ); +}); + +test('rejects a Core journal entry overwritten before collection', async () => { + const runtime = new ScriptedRuntime(); + runtime.coreJournalMutation = (journal) => { + const copy = structuredClone(journal) as any; + copy.snapshot.droppedBeforeSequence = journal.sequence + 1; + return copy; + }; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /no longer retains/, + ); +}); + +test('binds every Core completion to the actual scheduler round job ID', async () => { + const runtime = new ScriptedRuntime(); + runtime.coreJournalMutation = (journal) => { + const copy = structuredClone(journal) as any; + copy.snapshot.entries[0].completions[0].jobId = 'unbound-child-job'; + return copy; + }; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /lacks a terminal verified completion/, + ); +}); + +test('requires the exact runtime journal capacity from the node-admin contract', async () => { + const runtime = new ScriptedRuntime(); + runtime.coreJournalMutation = (journal) => { + const copy = structuredClone(journal) as any; + copy.snapshot.capacity = 257; + return copy; + }; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /journal snapshot is malformed/, + ); +}); + +test('accepts the truthful Node process-start epoch used by the runtime journal', async () => { + const runtime = new ScriptedRuntime(); + const processStartedAt = 1_753_000_000_123; + runtime.readyMutation = (ready) => ready.role === 'core' + ? { ...ready, processStartedAt } + : ready; + runtime.coreJournalMutation = (journal) => { + const copy = structuredClone(journal) as any; + copy.snapshot.processStartedAt = processStartedAt; + return copy; + }; + await assert.doesNotReject( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + ); +}); + +test('requires restart to cross an OS process boundary', async () => { + const runtime = new ScriptedRuntime(); + runtime.readyMutation = (ready) => ready.role === 'edge' && ready.pid === 103 + ? { ...ready, pid: 102 } + : ready; + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /does not prove old-process exit/, + ); +}); + +test('requires positive proof that the previous Edge process exited', async () => { + const runtime = new ScriptedRuntime(); + runtime.restartReceiptMutation = (receipt) => ({ + ...receipt, + previous: { ...receipt.previous, exitedAt: -1 }, + }); + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /does not prove old-process exit/, + ); +}); + +test('requires the replacement Edge process to start after the prior exit', async () => { + const runtime = new ScriptedRuntime(); + runtime.restartReceiptMutation = (receipt) => ({ + ...receipt, + previous: { ...receipt.previous, exitedAt: receipt.current.processStartedAt + 1 }, + }); + await assert.rejects( + collectSelectiveCoverageEvidenceV1({ corpus, expectedProvenance: expected, runtime }), + /does not prove old-process exit/, + ); +}); + +test('does not publish a PASS artifact when controller shutdown fails', async () => { + let published = false; + await assert.rejects( + runSelectiveCoverageLiveV1({ + collect: async () => ({ pass: true }), + close: async () => { + throw new Error('shutdown timeout'); + }, + publish: async () => { + published = true; + }, + }), + /shutdown timeout/, + ); + assert.equal(published, false); +}); diff --git a/devnet/rfc64-m1-selective-coverage/runtime.ts b/devnet/rfc64-m1-selective-coverage/runtime.ts new file mode 100644 index 0000000000..cf3084d444 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/runtime.ts @@ -0,0 +1,433 @@ +import { + type CoreAutomaticRoundV1, + type CoreFinalObservationV1, + type EdgeGraphObservationV1, + type EdgeSyncOperationV1, + type ExpectedSelectiveCoverageProvenanceV1, + type GraphObservationV1, + type SelectiveCoverageCorpusV1, + type SelectiveCoverageEvidenceV1, + SELECTIVE_COVERAGE_EVIDENCE_SCHEMA, + MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY, + computeSelectiveCoverageCorpusDigest, +} from './manifest.ts'; +import { verifySelectiveCoverage } from './verifier.ts'; +import { buildEdgeOperationPlan } from './edge-operation-plan.ts'; +import { + assertCoreAutomaticRoundJournalV1, + assertEdgeReconcilerJournalV1, + type SyncCoverageJournalReferenceV1, +} from './sync-coverage-journal.ts'; + +export const SELECTIVE_COVERAGE_RUNTIME_PROTOCOL = + 'dkg-rfc64-m1-selective-coverage-runtime-v1' as const; + +export type SelectiveCoverageRuntimeRole = 'publisher' | 'edge' | 'core'; + +export interface SelectiveCoverageRuntimeReadyV1 { + readonly protocol: typeof SELECTIVE_COVERAGE_RUNTIME_PROTOCOL; + readonly role: SelectiveCoverageRuntimeRole; + readonly pid: number; + readonly peerId: string; + readonly networkId: string; + readonly testedHeadCommit: string; + readonly runtimeManifestDigest: string; + /** Node process-start epoch milliseconds, sourced from performance.timeOrigin. */ + readonly processStartedAt: number; + /** Per-process unguessable instance identity, not the stable DKG peer ID. */ + readonly processInstanceId: string; + /** Stable identity for the durable directory reused across an Edge restart. */ + readonly dataDirectoryIdentity: string; + /** Process-local sync evidence wave emitted by the node journal. */ + readonly evidenceWaveId: string; +} + +export interface SelectiveCoverageEdgeRestartReceiptV1 { + readonly previous: { + readonly pid: number; + readonly processInstanceId: string; + readonly exitedAt: number; + }; + readonly current: SelectiveCoverageRuntimeReadyV1; +} + +export interface SelectiveCoverageRuntimeV1 { + start(role: SelectiveCoverageRuntimeRole): Promise; + stop(role: SelectiveCoverageRuntimeRole): Promise; + publishWave(wave: 'selected' | 'final'): Promise; + observeEdge( + checkpoint: 'before-selection' | 'after-selection' | 'after-restart' + | 'after-second-on-demand', + ): Promise; + synchronizeEdge(input: { + readonly contextGraphId: string; + readonly phase: 'selection' | 'post-restart-explicit'; + readonly syncMode: 'always-on' | 'on-demand'; + readonly wave: EdgeSyncOperationV1['completedWave']; + }): Promise<{ + readonly operation: Omit; + /** Required only for post-restart automatic reconciler work. */ + readonly journal?: SyncCoverageJournalReferenceV1; + }>; + restartEdge(): Promise; + waitForEdgeReconciler(input: { + readonly contextGraphId: string; + }): Promise<{ + readonly operation: Omit; + readonly journal: SyncCoverageJournalReferenceV1; + }>; + runCoreAutomaticRound(round: number): Promise<{ + readonly round: CoreAutomaticRoundV1; + readonly journal: SyncCoverageJournalReferenceV1; + }>; + observeCoreFinal(): Promise; +} + +/** + * Run the user-visible M1 sequence without deriving expectations from a receiver. + * + * The corpus and provenance are immutable operator inputs. Runtime observations + * can satisfy them, but can never redefine them. The final verifier is invoked + * before evidence is returned, so callers cannot accidentally publish a + * metadata-only or otherwise incomplete artifact as a passing run. + */ +export async function collectSelectiveCoverageEvidenceV1(input: { + readonly corpus: SelectiveCoverageCorpusV1; + readonly expectedProvenance: ExpectedSelectiveCoverageProvenanceV1; + readonly runtime: SelectiveCoverageRuntimeV1; +}): Promise { + assertAnchoredCorpus(input.corpus, input.expectedProvenance); + const edgePlan = buildEdgeOperationPlan(input.corpus); + const attempted = new Set(); + let primaryFailure: unknown; + try { + attempted.add('publisher'); + const publisher = await input.runtime.start('publisher'); + assertReady(publisher, 'publisher', input.expectedProvenance); + + attempted.add('edge'); + const edgeBeforeRestart = await input.runtime.start('edge'); + assertReady(edgeBeforeRestart, 'edge', input.expectedProvenance); + assertDistinctProcesses([publisher, edgeBeforeRestart]); + + const publisherSelected = canonicalGraphObservations( + await input.runtime.publishWave('selected'), + input.corpus, + 'Publisher selected wave', + ); + const edgeBeforeSelection = canonicalEdgeObservations( + await input.runtime.observeEdge('before-selection'), + input.corpus, + 'Edge before selection', + ); + + const edgeOperations: EdgeSyncOperationV1[] = []; + const edgeReconcilerJournals: SyncCoverageJournalReferenceV1[] = []; + for (const step of edgePlan.selection) { + const result = await input.runtime.synchronizeEdge({ + contextGraphId: step.contextGraphId, + phase: step.phase, + syncMode: step.syncMode, + wave: step.completedWave, + }); + edgeOperations.push(withSequence(result.operation, edgeOperations.length)); + } + const edgeAfterSelection = canonicalEdgeObservations( + await input.runtime.observeEdge('after-selection'), + input.corpus, + 'Edge after selection', + ); + + const publisherFinal = canonicalGraphObservations( + await input.runtime.publishWave('final'), + input.corpus, + 'Publisher final wave', + ); + + const edgeRestart = await input.runtime.restartEdge(); + assertEdgeRestartReceipt(edgeRestart, edgeBeforeRestart); + const edgeAfterRestartReady = edgeRestart.current; + assertReady(edgeAfterRestartReady, 'edge', input.expectedProvenance); + assertDistinctProcesses([publisher, edgeBeforeRestart, edgeAfterRestartReady]); + + for (const step of edgePlan.postRestartAutomatic) { + const result = await input.runtime.waitForEdgeReconciler({ + contextGraphId: step.contextGraphId, + }); + assertEdgeReconcilerJournalV1( + result.journal, + result.operation, + edgeAfterRestartReady, + ); + edgeReconcilerJournals.push(result.journal); + edgeOperations.push(withSequence(result.operation, edgeOperations.length)); + } + const edgeAfterRestart = canonicalEdgeObservations( + await input.runtime.observeEdge('after-restart'), + input.corpus, + 'Edge after restart', + ); + + for (const step of edgePlan.postRestartExplicit) { + const result = await input.runtime.synchronizeEdge({ + contextGraphId: step.contextGraphId, + phase: step.phase, + syncMode: step.syncMode, + wave: step.completedWave, + }); + edgeOperations.push(withSequence(result.operation, edgeOperations.length)); + } + const edgeAfterSecondOnDemand = canonicalEdgeObservations( + await input.runtime.observeEdge('after-second-on-demand'), + input.corpus, + 'Edge after second on-demand request', + ); + + attempted.add('core'); + const core = await input.runtime.start('core'); + assertReady(core, 'core', input.expectedProvenance); + assertDistinctProcesses([publisher, edgeAfterRestartReady, core]); + + const rounds: CoreAutomaticRoundV1[] = []; + const coreRoundJournals: SyncCoverageJournalReferenceV1[] = []; + const scheduled = new Set(); + const publicIds = new Set( + input.corpus.graphs + .filter((graph) => graph.accessPolicy === 0) + .map((graph) => graph.contextGraphId), + ); + for (let round = 0; round < input.corpus.coreCoverageRoundLimit; round += 1) { + const result = await input.runtime.runCoreAutomaticRound(round); + const observed = result.round; + assertCoreRoundEnvelope(observed, round, input.corpus, publisher.peerId); + assertCoreAutomaticRoundJournalV1(result.journal, observed, core); + rounds.push(observed); + coreRoundJournals.push(result.journal); + for (const contextGraphId of observed.contextGraphIds) scheduled.add(contextGraphId); + if ([...publicIds].every((contextGraphId) => scheduled.has(contextGraphId))) break; + } + const coreFinal = canonicalCoreObservations( + await input.runtime.observeCoreFinal(), + input.corpus, + 'Core final', + ); + + const evidence: SelectiveCoverageEvidenceV1 = { + schema: SELECTIVE_COVERAGE_EVIDENCE_SCHEMA, + provenance: { + networkId: publisher.networkId, + testedHeadCommit: publisher.testedHeadCommit, + runtimeManifestDigest: publisher.runtimeManifestDigest, + publisherPeerId: publisher.peerId, + edgePeerId: edgeAfterRestartReady.peerId, + corePeerId: core.peerId, + }, + automaticJournalEvidence: { + edgeProcess: { + processStartedAt: edgeAfterRestartReady.processStartedAt, + evidenceWaveId: edgeAfterRestartReady.evidenceWaveId, + }, + edgeReconciler: Object.freeze(edgeReconcilerJournals), + coreProcess: { + processStartedAt: core.processStartedAt, + evidenceWaveId: core.evidenceWaveId, + }, + coreRounds: Object.freeze(coreRoundJournals), + }, + corpus: input.corpus, + publisher: { + selected: publisherSelected, + final: publisherFinal, + }, + edge: { + beforeSelection: edgeBeforeSelection, + afterSelection: edgeAfterSelection, + afterRestart: edgeAfterRestart, + afterSecondOnDemand: edgeAfterSecondOnDemand, + operations: Object.freeze(edgeOperations), + }, + core: { + automaticBatchSize: input.corpus.coreAutomaticBatchSize, + rounds: Object.freeze(rounds), + final: coreFinal, + }, + }; + const detachedEvidence = detachJsonEvidence(evidence); + const verdict = verifySelectiveCoverage(detachedEvidence, input.expectedProvenance); + if (!verdict.pass) { + throw new Error( + `M1 runtime evidence failed closed: ${verdict.rejectReasons.join('; ')}`, + ); + } + return detachedEvidence; + } catch (error) { + primaryFailure = error; + throw error; + } finally { + const cleanupFailures: unknown[] = []; + for (const role of [...attempted].reverse()) { + try { + await input.runtime.stop(role); + } catch (error) { + cleanupFailures.push(error); + } + } + if (primaryFailure === undefined && cleanupFailures.length > 0) { + throw new AggregateError(cleanupFailures, 'M1 runtime cleanup failed'); + } + } +} + +function assertEdgeRestartReceipt( + receipt: SelectiveCoverageEdgeRestartReceiptV1, + previous: SelectiveCoverageRuntimeReadyV1, +): void { + if (receipt.previous.pid !== previous.pid + || receipt.previous.processInstanceId !== previous.processInstanceId + || !Number.isSafeInteger(receipt.previous.exitedAt) + || receipt.previous.exitedAt < previous.processStartedAt + || receipt.current.processStartedAt < receipt.previous.exitedAt + || receipt.current.pid === previous.pid + || receipt.current.processInstanceId === previous.processInstanceId + || receipt.current.dataDirectoryIdentity !== previous.dataDirectoryIdentity) { + throw new Error('Edge restart receipt does not prove old-process exit and durable reuse'); + } +} + +function detachJsonEvidence( + evidence: SelectiveCoverageEvidenceV1, +): SelectiveCoverageEvidenceV1 { + try { + // Runtime responses cross JSON in production. Detaching here gives an + // injected/in-process adapter the same boundary and prevents shared object + // identities from making the final canonical artifact ambiguous. + return JSON.parse(JSON.stringify(evidence)) as SelectiveCoverageEvidenceV1; + } catch (error) { + throw new Error('M1 runtime evidence is not lossless JSON', { cause: error }); + } +} + +function withSequence( + operation: Omit, + sequence: number, +): EdgeSyncOperationV1 { + return { sequence, ...operation }; +} + +function assertAnchoredCorpus( + corpus: SelectiveCoverageCorpusV1, + expected: ExpectedSelectiveCoverageProvenanceV1, +): void { + if (computeSelectiveCoverageCorpusDigest(corpus) !== corpus.manifestDigest) { + throw new Error('M1 corpus manifest digest does not match its payload'); + } + if (corpus.manifestDigest !== expected.corpusManifestDigest) { + throw new Error('M1 corpus differs from the external trust anchor'); + } + if (corpus.networkId !== expected.networkId) { + throw new Error('M1 corpus network differs from the external trust anchor'); + } + if (corpus.coreAutomaticBatchSize > MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY) { + throw new Error('M1 Core batch exceeds the bounded journal entry capacity'); + } + for (const graph of corpus.graphs) { + if (graph.accessPolicy === 1 && graph.edgePolicy !== 'unselected') { + throw new Error('M1 private graphs must remain unselected in the Edge slice'); + } + } +} + +function assertReady( + actual: SelectiveCoverageRuntimeReadyV1, + role: SelectiveCoverageRuntimeRole, + expected: ExpectedSelectiveCoverageProvenanceV1, +): void { + const expectedPeerId = role === 'publisher' + ? expected.publisherPeerId + : role === 'edge' + ? expected.edgePeerId + : expected.corePeerId; + if (actual.protocol !== SELECTIVE_COVERAGE_RUNTIME_PROTOCOL + || actual.role !== role + || !Number.isSafeInteger(actual.pid) + || actual.pid <= 0 + || actual.peerId !== expectedPeerId + || actual.networkId !== expected.networkId + || actual.testedHeadCommit !== expected.testedHeadCommit + || actual.runtimeManifestDigest !== expected.runtimeManifestDigest) { + throw new Error(`${role} runtime identity differs from the external trust anchor`); + } + for (const [label, value] of [ + ['processInstanceId', actual.processInstanceId], + ['dataDirectoryIdentity', actual.dataDirectoryIdentity], + ['evidenceWaveId', actual.evidenceWaveId], + ] as const) { + if (typeof value !== 'string' || value.length < 1 || value.length > 256) { + throw new Error(`${role} runtime ${label} is invalid`); + } + } + if (!Number.isSafeInteger(actual.processStartedAt) || actual.processStartedAt < 0) { + throw new Error(`${role} runtime processStartedAt is invalid`); + } +} + +function assertDistinctProcesses( + processes: readonly SelectiveCoverageRuntimeReadyV1[], +): void { + if (new Set(processes.map((entry) => entry.pid)).size !== processes.length) { + throw new Error('M1 roles did not cross distinct OS process boundaries'); + } + const peerByRole = new Map(); + for (const process of processes) peerByRole.set(process.role, process.peerId); + if (new Set(peerByRole.values()).size !== peerByRole.size) { + throw new Error('M1 roles did not use distinct DKG peer identities'); + } +} + +function canonicalGraphObservations( + observations: readonly T[], + corpus: SelectiveCoverageCorpusV1, + label: string, +): readonly T[] { + const expectedIds = corpus.graphs.map((graph) => graph.contextGraphId); + const byId = new Map(observations.map((row) => [row.contextGraphId, row])); + if (observations.length !== expectedIds.length || byId.size !== expectedIds.length) { + throw new Error(`${label} did not return exactly one row per anchored graph`); + } + const canonical = expectedIds.map((contextGraphId) => byId.get(contextGraphId)); + if (canonical.some((row) => row === undefined)) { + throw new Error(`${label} omitted an anchored graph`); + } + return Object.freeze(canonical as T[]); +} + +function canonicalEdgeObservations( + observations: readonly EdgeGraphObservationV1[], + corpus: SelectiveCoverageCorpusV1, + label: string, +): readonly EdgeGraphObservationV1[] { + return canonicalGraphObservations(observations, corpus, label); +} + +function canonicalCoreObservations( + observations: readonly CoreFinalObservationV1[], + corpus: SelectiveCoverageCorpusV1, + label: string, +): readonly CoreFinalObservationV1[] { + return canonicalGraphObservations(observations, corpus, label); +} + +function assertCoreRoundEnvelope( + observed: CoreAutomaticRoundV1, + round: number, + corpus: SelectiveCoverageCorpusV1, + publisherPeerId: string, +): void { + if (observed.round !== round + || observed.source !== 'automatic-core-public' + || observed.planningLane !== publisherPeerId + || observed.configuredBatchSize !== corpus.coreAutomaticBatchSize + || observed.explicitSelectedContextGraphIds.length !== 0) { + throw new Error(`Core round ${round} is not scheduler-issued automatic coverage`); + } +} diff --git a/devnet/rfc64-m1-selective-coverage/sync-coverage-journal.ts b/devnet/rfc64-m1-selective-coverage/sync-coverage-journal.ts new file mode 100644 index 0000000000..8e4cad988b --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/sync-coverage-journal.ts @@ -0,0 +1,187 @@ +import type { + CoreAutomaticRoundV1, + EdgeSyncOperationV1, + SyncCoverageJournalProcessIdentityV1, + SyncCoverageJournalReferenceV1, +} from './manifest.ts'; +import { MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY } from './manifest.ts'; +import { + boundedString, + closedArray, + closedRecord, + defineRecordKeys, + nonNegativeInteger, + plainRecord, + positiveInteger, +} from './boundary-codec.ts'; + +const JOURNAL_CAPACITY = 256; +const MAX_CONTEXT_GRAPH_ID_LENGTH = 256; +const SYNC_COVERAGE_JOURNAL_REFERENCE_KEYS = defineRecordKeys< + SyncCoverageJournalReferenceV1 +>()('snapshot', 'sequence'); + +export type { + SyncCoverageJournalProcessIdentityV1, + SyncCoverageJournalReferenceV1, +} from './manifest.ts'; + +/** Parse the closed outer reference before retaining untrusted journal JSON. */ +export function parseSyncCoverageJournalReferenceV1( + input: unknown, +): SyncCoverageJournalReferenceV1 | undefined { + const row = closedRecord(input, SYNC_COVERAGE_JOURNAL_REFERENCE_KEYS); + if (!row || !nonNegativeInteger(row['sequence'])) return undefined; + return { snapshot: row['snapshot'], sequence: row['sequence'] }; +} + +/** + * Bind an automatic Edge completion to the immutable operator journal. The + * exact VM/SWM snapshot remains independently queried by the harness. + */ +export function assertEdgeReconcilerJournalV1( + reference: SyncCoverageJournalReferenceV1 | undefined, + operation: Omit, + process: SyncCoverageJournalProcessIdentityV1, +): void { + const entry = terminalEntry(reference, 'edge-reconciler-job', process); + if (entry['jobId'] !== operation.jobId + || entry['contextGraphId'] !== operation.contextGraphId + || entry['source'] !== 'reconciler' + || entry['trigger'] !== 'periodic-reconciler' + || entry['syncMode'] !== 'always-on' + || !positiveInteger(entry['rehydratedSelectionCount']) + || !verifiedPlanes(entry['verified'])) { + throw new Error('Edge automatic completion lacks exact reconciler journal provenance'); + } +} + +/** Bind one claimed Core round to its frozen scheduler plan and completions. */ +export function assertCoreAutomaticRoundJournalV1( + reference: SyncCoverageJournalReferenceV1 | undefined, + round: CoreAutomaticRoundV1, + process: SyncCoverageJournalProcessIdentityV1, +): void { + const entry = terminalEntry(reference, 'core-automatic-round', process); + const automaticIds = stringArray(entry['automaticContextGraphIds']); + const explicitIds = stringArray(entry['explicitSelectedContextGraphIds']); + const completions = requiredArray( + entry['completions'], + MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY, + 'Core completions', + ); + const effectiveBatchSize = entry['effectiveBatchSize']; + if (entry['jobId'] !== round.jobId + || entry['planningLane'] !== round.planningLane + || entry['source'] !== 'automatic-core-public' + || !isCoreAutomaticTriggerV1(entry['trigger']) + || entry['configuredBatchSize'] !== round.configuredBatchSize + || !nonNegativeInteger(effectiveBatchSize) + || effectiveBatchSize > round.configuredBatchSize + || automaticIds.length > effectiveBatchSize + || entry['automaticContextGraphCount'] !== automaticIds.length + || entry['explicitSelectedContextGraphCount'] !== explicitIds.length + || !sameStrings(automaticIds, round.contextGraphIds) + || !sameStrings(explicitIds, round.explicitSelectedContextGraphIds) + || round.completions.length !== round.contextGraphIds.length + || completions.length !== automaticIds.length) { + throw new Error('Core round differs from its immutable scheduler journal plan'); + } + for (let index = 0; index < round.completions.length; index += 1) { + const expected = round.completions[index]!; + const completion = completions[index]; + const completionRow = plainRecord(completion); + if (!completionRow + || expected.contextGraphId !== round.contextGraphIds[index] + || completionRow['contextGraphId'] !== automaticIds[index] + || completionRow['jobId'] !== round.jobId + || completionRow['state'] !== 'complete' + || !verifiedPlanes(completionRow['verified']) + || !nonNegativeInteger(completionRow['finishedAt'])) { + throw new Error('Core round lacks a terminal verified completion for a planned graph'); + } + } +} + +function isCoreAutomaticTriggerV1(value: unknown): boolean { + return value === 'connection-open' + || value === 'peer-update' + || value === 'periodic-reconciler'; +} + +function terminalEntry( + reference: SyncCoverageJournalReferenceV1 | undefined, + kind: 'edge-reconciler-job' | 'core-automatic-round', + process: SyncCoverageJournalProcessIdentityV1, +): Record { + if (!reference || !nonNegativeInteger(reference.sequence)) { + throw new Error(`${kind} requires an operator-journal terminal entry`); + } + const snapshot = reference.snapshot; + const snapshotRow = plainRecord(snapshot); + if (!snapshotRow + || snapshotRow['schemaVersion'] !== 1 + || snapshotRow['processStartedAt'] !== process.processStartedAt + || snapshotRow['waveId'] !== process.evidenceWaveId + || snapshotRow['capacity'] !== JOURNAL_CAPACITY + || !nonNegativeInteger(snapshotRow['nextSequence']) + || !nonNegativeInteger(snapshotRow['droppedBeforeSequence'])) { + throw new Error('Sync coverage journal snapshot is malformed'); + } + if ((snapshotRow['droppedBeforeSequence'] as number) > reference.sequence + || (snapshotRow['nextSequence'] as number) <= reference.sequence) { + throw new Error('Sync coverage journal no longer retains the referenced evidence'); + } + const entries = requiredArray(snapshotRow['entries'], JOURNAL_CAPACITY, 'journal entries'); + if (entries.length > (snapshotRow['capacity'] as number)) { + throw new Error('Sync coverage journal exceeds its declared capacity'); + } + const candidate = entries.find((entry) => { + const row = plainRecord(entry); + return row !== undefined && row['sequence'] === reference.sequence; + }); + const candidateRow = plainRecord(candidate); + if (!candidateRow + || candidateRow['kind'] !== kind + || candidateRow['waveId'] !== snapshotRow['waveId'] + || candidateRow['evidenceTruncated'] !== false + || candidateRow['state'] !== 'complete' + || !nonNegativeInteger(candidateRow['startedAt']) + || !nonNegativeInteger(candidateRow['finishedAt'])) { + throw new Error(`${kind} terminal journal entry is missing, truncated, or incomplete`); + } + return candidateRow; +} + +function verifiedPlanes(value: unknown): boolean { + const row = plainRecord(value); + return row !== undefined + && row['metadata'] === true + && row['durable'] === true + && row['sharedMemory'] === true; +} + +function stringArray(value: unknown): string[] { + const values = requiredArray( + value, + MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY, + 'context graph IDs', + ); + if (values.length > MAX_SYNC_COVERAGE_IDS_PER_JOURNAL_ENTRY + || values.some((entry) => boundedString(entry, 1, MAX_CONTEXT_GRAPH_ID_LENGTH) === undefined) + || new Set(values).size !== values.length) { + throw new Error('Sync coverage journal contains invalid context graph IDs'); + } + return values as string[]; +} + +function requiredArray(value: unknown, maximum: number, label: string): unknown[] { + if (!closedArray(value, 0, maximum)) { + throw new Error(`Sync coverage journal ${label} is not a bounded plain array`); + } + return value; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/devnet/rfc64-m1-selective-coverage/tsconfig.json b/devnet/rfc64-m1-selective-coverage/tsconfig.json new file mode 100644 index 0000000000..37f50de209 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "declaration": false, + "declarationMap": false, + "noEmit": true, + "rootDir": "../..", + "skipLibCheck": true, + "sourceMap": false, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/devnet/rfc64-m1-selective-coverage/verifier.test.ts b/devnet/rfc64-m1-selective-coverage/verifier.test.ts new file mode 100644 index 0000000000..5a9cd86809 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/verifier.test.ts @@ -0,0 +1,883 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import test from 'node:test'; + +import { + SELECTIVE_COVERAGE_EVIDENCE_SCHEMA, + canonicalJson, + createSelectiveCoverageCorpus, + type GraphObservationV1, + type GraphSnapshotExpectationV1, + type ExpectedSelectiveCoverageProvenanceV1, + type SelectiveCoverageEvidenceV1, + type SelectiveCoverageGraphV1, +} from './manifest.ts'; +import { verifySelectiveCoverage as verifyWithProvenance } from './verifier.ts'; + +const id = (name: string) => `0x1111111111111111111111111111111111111111/${name}`; +const hash = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`; + +function snapshot(name: string, wave: 'selected' | 'final'): GraphSnapshotExpectationV1 { + const offset = wave === 'selected' ? 1 : 2; + return { + vm: { + headDigest: hash(`${name}:${wave}:vm:head`), + inventoryDigest: hash(`${name}:${wave}:vm:inventory`), + assetCount: offset, + dataTripleCount: 100 * offset, + }, + swm: { + headDigest: hash(`${name}:${wave}:swm:head`), + inventoryDigest: hash(`${name}:${wave}:swm:inventory`), + assetCount: offset, + dataTripleCount: 80 * offset, + }, + }; +} + +const graphs: readonly SelectiveCoverageGraphV1[] = [ + { name: '01-public-open-on-demand', accessPolicy: 0 as const, publishPolicy: 1 as const, edgePolicy: 'on-demand' as const }, + { name: '02-public-curated-always-on', accessPolicy: 0 as const, publishPolicy: 0 as const, edgePolicy: 'always-on' as const }, + { name: '03-public-open-unselected', accessPolicy: 0 as const, publishPolicy: 1 as const, edgePolicy: 'unselected' as const }, + { name: '04-private-open', accessPolicy: 1 as const, publishPolicy: 1 as const, edgePolicy: 'unselected' as const }, + { name: '05-private-curated', accessPolicy: 1 as const, publishPolicy: 0 as const, edgePolicy: 'unselected' as const }, +].map((cell) => ({ + contextGraphId: id(cell.name), + accessPolicy: cell.accessPolicy, + publishPolicy: cell.publishPolicy, + edgePolicy: cell.edgePolicy, + selectedSnapshot: snapshot(cell.name, 'selected'), + finalSnapshot: snapshot(cell.name, 'final'), +})); + +const corpus = createSelectiveCoverageCorpus({ + networkId: 'otp:20430', + coreAutomaticBatchSize: 2, + coreCoverageRoundLimit: 2, + graphs, +}); + +const PROVENANCE = Object.freeze({ + networkId: corpus.networkId, + testedHeadCommit: 'a'.repeat(40), + runtimeManifestDigest: hash('runtime-manifest'), + publisherPeerId: 'publisher-peer', + edgePeerId: 'edge-peer', + corePeerId: 'core-peer', +}); +const EXPECTED_PROVENANCE: ExpectedSelectiveCoverageProvenanceV1 = Object.freeze({ + ...PROVENANCE, + corpusManifestDigest: corpus.manifestDigest, +}); + +function verifySelectiveCoverage(input: unknown) { + return verifyWithProvenance(input, EXPECTED_PROVENANCE); +} + +function absent(contextGraphId: string): GraphObservationV1 { + const plane = { + reportedComplete: false, + headDigest: null, + inventoryDigest: null, + assetCount: 0, + metadataTripleCount: 0, + dataTripleCount: 0, + } as const; + return { contextGraphId, vm: { ...plane }, swm: { ...plane } }; +} + +function exact( + graph: SelectiveCoverageGraphV1, + expected: GraphSnapshotExpectationV1, +): GraphObservationV1 { + const plane = (value: GraphSnapshotExpectationV1['vm']) => ({ + reportedComplete: true, + headDigest: value.headDigest, + inventoryDigest: value.inventoryDigest, + assetCount: value.assetCount, + metadataTripleCount: 5, + dataTripleCount: value.dataTripleCount, + }); + return { + contextGraphId: graph.contextGraphId, + vm: plane(expected.vm), + swm: plane(expected.swm), + }; +} + +function edgeAbsent(contextGraphId: string) { + return { + ...absent(contextGraphId), + runtimeSyncMode: null, + producingJobId: null, + } as const; +} + +function edgeExact( + graph: SelectiveCoverageGraphV1, + expected: GraphSnapshotExpectationV1, + producingJobId: string, +) { + return { + ...exact(graph, expected), + runtimeSyncMode: graph.edgePolicy as 'on-demand' | 'always-on', + producingJobId, + }; +} + +function journalReference( + entry: Record, + processStartedAt: number, +) { + const sequence = entry.sequence as number; + return { + sequence, + snapshot: { + schemaVersion: 1, + processStartedAt, + waveId: entry.waveId, + capacity: 256, + nextSequence: sequence + 1, + droppedBeforeSequence: 0, + entries: [entry], + }, + }; +} + +function coreJournal( + round: number, + jobId: string, + contextGraphIds: readonly string[], + configuredBatchSize = 2, +) { + return journalReference({ + kind: 'core-automatic-round', + sequence: round + 1, + waveId: 'core-wave', + jobId, + planningLane: 'publisher-peer', + source: 'automatic-core-public', + trigger: 'connection-open', + configuredBatchSize, + effectiveBatchSize: configuredBatchSize, + explicitSelectedContextGraphIds: [], + explicitSelectedContextGraphCount: 0, + automaticContextGraphIds: contextGraphIds, + automaticContextGraphCount: contextGraphIds.length, + evidenceTruncated: false, + state: 'complete', + startedAt: 20 + round, + finishedAt: 21 + round, + completions: contextGraphIds.map((contextGraphId) => ({ + jobId, + contextGraphId, + state: 'complete', + verified: { metadata: true, durable: true, sharedMemory: true }, + finishedAt: 21 + round, + })), + }, 2); +} + +function fixture(): SelectiveCoverageEvidenceV1 { + return { + schema: SELECTIVE_COVERAGE_EVIDENCE_SCHEMA, + provenance: PROVENANCE, + automaticJournalEvidence: { + edgeProcess: { processStartedAt: 1, evidenceWaveId: 'edge-wave' }, + edgeReconciler: [journalReference({ + kind: 'edge-reconciler-job', + sequence: 1, + waveId: 'edge-wave', + jobId: 'edge-auto-always-on', + contextGraphId: graphs[1]!.contextGraphId, + source: 'reconciler', + trigger: 'periodic-reconciler', + syncMode: 'always-on', + rehydratedSelectionCount: 1, + evidenceTruncated: false, + state: 'complete', + verified: { metadata: true, durable: true, sharedMemory: true }, + startedAt: 10, + finishedAt: 11, + }, 1)], + coreProcess: { processStartedAt: 2, evidenceWaveId: 'core-wave' }, + coreRounds: [ + coreJournal(0, 'core-auto-0', [graphs[0]!.contextGraphId, graphs[1]!.contextGraphId]), + coreJournal(1, 'core-auto-1', [graphs[2]!.contextGraphId]), + ], + }, + corpus, + publisher: { + selected: corpus.graphs.map((graph) => exact(graph, graph.selectedSnapshot)), + final: corpus.graphs.map((graph) => exact(graph, graph.finalSnapshot)), + }, + edge: { + beforeSelection: corpus.graphs.map((graph) => edgeAbsent(graph.contextGraphId)), + afterSelection: corpus.graphs.map((graph) => + graph.accessPolicy === 0 && graph.edgePolicy !== 'unselected' + ? edgeExact( + graph, + graph.selectedSnapshot, + graph.edgePolicy === 'on-demand' + ? 'edge-select-on-demand' + : 'edge-select-always-on', + ) + : edgeAbsent(graph.contextGraphId)), + afterRestart: corpus.graphs.map((graph) => { + if (graph.accessPolicy !== 0 || graph.edgePolicy === 'unselected') { + return edgeAbsent(graph.contextGraphId); + } + const alwaysOn = graph.edgePolicy === 'always-on'; + const observed = edgeExact( + graph, + alwaysOn ? graph.finalSnapshot : graph.selectedSnapshot, + alwaysOn ? 'edge-auto-always-on' : 'edge-select-on-demand', + ); + return alwaysOn ? observed : { ...observed, runtimeSyncMode: null }; + }), + afterSecondOnDemand: corpus.graphs.map((graph) => + graph.accessPolicy === 0 && graph.edgePolicy !== 'unselected' + ? edgeExact( + graph, + graph.finalSnapshot, + graph.edgePolicy === 'on-demand' + ? 'edge-second-on-demand' + : 'edge-auto-always-on', + ) + : edgeAbsent(graph.contextGraphId)), + operations: [ + { + sequence: 0, + phase: 'selection', + source: 'user', + syncMode: 'on-demand', + contextGraphId: graphs[0]!.contextGraphId, + jobId: 'edge-select-on-demand', + completedWave: 'selected', + completedSnapshot: graphs[0]!.selectedSnapshot, + }, + { + sequence: 1, + phase: 'selection', + source: 'user', + syncMode: 'always-on', + contextGraphId: graphs[1]!.contextGraphId, + jobId: 'edge-select-always-on', + completedWave: 'selected', + completedSnapshot: graphs[1]!.selectedSnapshot, + }, + { + sequence: 2, + phase: 'post-restart-auto', + source: 'reconciler', + syncMode: 'always-on', + contextGraphId: graphs[1]!.contextGraphId, + jobId: 'edge-auto-always-on', + completedWave: 'final', + completedSnapshot: graphs[1]!.finalSnapshot, + }, + { + sequence: 3, + phase: 'post-restart-explicit', + source: 'user', + syncMode: 'on-demand', + contextGraphId: graphs[0]!.contextGraphId, + jobId: 'edge-second-on-demand', + completedWave: 'final', + completedSnapshot: graphs[0]!.finalSnapshot, + }, + ], + }, + core: { + automaticBatchSize: 2, + rounds: [ + { + round: 0, + jobId: 'core-auto-0', + planningLane: 'publisher-peer', + source: 'automatic-core-public', + configuredBatchSize: 2, + explicitSelectedContextGraphIds: [], + contextGraphIds: [graphs[0]!.contextGraphId, graphs[1]!.contextGraphId], + completions: [graphs[0]!, graphs[1]!].map((graph) => ({ + contextGraphId: graph.contextGraphId, + completedWave: 'final' as const, + completedSnapshot: graph.finalSnapshot, + })), + }, + { + round: 1, + jobId: 'core-auto-1', + planningLane: 'publisher-peer', + source: 'automatic-core-public', + configuredBatchSize: 2, + explicitSelectedContextGraphIds: [], + contextGraphIds: [graphs[2]!.contextGraphId], + completions: [{ + contextGraphId: graphs[2]!.contextGraphId, + completedWave: 'final', + completedSnapshot: graphs[2]!.finalSnapshot, + }], + }, + ], + final: corpus.graphs.map((graph, index) => ({ + ...(graph.accessPolicy === 0 + ? exact(graph, graph.finalSnapshot) + : absent(graph.contextGraphId)), + automaticJobIds: graph.accessPolicy === 0 + ? [index < 2 ? 'core-auto-0' : 'core-auto-1'] + : [], + })), + }, + }; +} + +function clone(): any { + return JSON.parse(JSON.stringify(fixture())); +} + +test('accepts exact Edge selection and bounded Core public convergence evidence', () => { + const verdict = verifySelectiveCoverage(fixture()); + assert.equal(verdict.pass, true); + assert.deepEqual(verdict.rejectReasons, []); + assert.deepEqual(verdict.missingCoreContextGraphIds, []); + for (const [name, value] of Object.entries(verdict.checks)) { + assert.equal(value, true, name); + } +}); + +test('rejects Edge payload, subscription, or job evidence before selection', () => { + const payloadLeak = clone(); + payloadLeak.edge.beforeSelection[0] = { + ...exact(graphs[0]!, graphs[0]!.selectedSnapshot), + runtimeSyncMode: null, + producingJobId: null, + }; + let verdict = verifySelectiveCoverage(payloadLeak); + assert.equal(verdict.checks.edgePassiveBeforeSelection, false); + assert.equal(verdict.pass, false); + + const subscriptionLeak = clone(); + subscriptionLeak.edge.beforeSelection[0].runtimeSyncMode = 'on-demand'; + verdict = verifySelectiveCoverage(subscriptionLeak); + assert.equal(verdict.checks.edgePassiveBeforeSelection, false); + assert.equal(verdict.pass, false); + + const jobLeak = clone(); + jobLeak.edge.beforeSelection[0].producingJobId = 'premature-edge-job'; + verdict = verifySelectiveCoverage(jobLeak); + assert.equal(verdict.checks.edgePassiveBeforeSelection, false); + assert.equal(verdict.pass, false); +}); + +test('published artifact must retain matching automatic journal proof', () => { + const missing = clone(); + delete missing.automaticJournalEvidence; + assert.equal(verifySelectiveCoverage(missing).checks.schemaWellFormed, false); + + const relabelled = clone(); + relabelled.edge.operations[2].jobId = 'synthetic-reconciler-job'; + relabelled.edge.afterRestart[1].producingJobId = 'synthetic-reconciler-job'; + relabelled.edge.afterSecondOnDemand[1].producingJobId = 'synthetic-reconciler-job'; + const edgeVerdict = verifySelectiveCoverage(relabelled); + assert.equal(edgeVerdict.checks.edgeOperationProvenance, false); + + const syntheticCore = clone(); + syntheticCore.core.rounds[0].jobId = 'synthetic-core-round'; + syntheticCore.core.final[0].automaticJobIds = ['synthetic-core-round']; + syntheticCore.core.final[1].automaticJobIds = ['synthetic-core-round']; + const coreVerdict = verifySelectiveCoverage(syntheticCore); + assert.equal(coreVerdict.checks.coreAutomaticProvenance, false); + + const exceedsPlannedCapacity = clone(); + exceedsPlannedCapacity.automaticJournalEvidence.coreRounds[0] + .snapshot.entries[0].effectiveBatchSize = 1; + assert.equal( + verifySelectiveCoverage(exceedsPlannedCapacity).checks.coreAutomaticProvenance, + false, + ); + + const exceedsConfiguredCapacity = clone(); + exceedsConfiguredCapacity.automaticJournalEvidence.coreRounds[0] + .snapshot.entries[0].effectiveBatchSize = 3; + assert.equal( + verifySelectiveCoverage(exceedsConfiguredCapacity).checks.coreAutomaticProvenance, + false, + ); +}); + +test('manual catch-up cannot satisfy Core automatic provenance', () => { + const manualCatchup = clone(); + manualCatchup.automaticJournalEvidence.coreRounds[0] + .snapshot.entries[0].trigger = 'manual-catchup'; + + const verdict = verifySelectiveCoverage(manualCatchup); + assert.equal(verdict.checks.coreAutomaticProvenance, false); + assert.equal(verdict.pass, false); +}); + +test('supports 33 public graphs through multiple bounded journal rounds', () => { + const extra = Array.from({ length: 30 }, (_, index): SelectiveCoverageGraphV1 => { + const name = `03-public-unselected-${String(index).padStart(2, '0')}`; + return { + contextGraphId: id(name), + accessPolicy: 0, + publishPolicy: 1, + edgePolicy: 'unselected', + selectedSnapshot: snapshot(name, 'selected'), + finalSnapshot: snapshot(name, 'final'), + }; + }); + const largeCorpus = createSelectiveCoverageCorpus({ + networkId: corpus.networkId, + coreAutomaticBatchSize: 32, + coreCoverageRoundLimit: 2, + graphs: [...graphs, ...extra], + }); + const evidence = clone(); + evidence.corpus = largeCorpus; + evidence.publisher.selected = largeCorpus.graphs.map((graph) => + exact(graph, graph.selectedSnapshot)); + evidence.publisher.final = largeCorpus.graphs.map((graph) => exact(graph, graph.finalSnapshot)); + evidence.edge.beforeSelection = largeCorpus.graphs.map((graph) => edgeAbsent(graph.contextGraphId)); + evidence.edge.afterSelection = largeCorpus.graphs.map((graph) => { + if (graph.contextGraphId === graphs[0]!.contextGraphId) { + return edgeExact(graph, graph.selectedSnapshot, 'edge-select-on-demand'); + } + if (graph.contextGraphId === graphs[1]!.contextGraphId) { + return edgeExact(graph, graph.selectedSnapshot, 'edge-select-always-on'); + } + return edgeAbsent(graph.contextGraphId); + }); + evidence.edge.afterRestart = largeCorpus.graphs.map((graph) => { + if (graph.contextGraphId === graphs[0]!.contextGraphId) { + return { + ...edgeExact(graph, graph.selectedSnapshot, 'edge-select-on-demand'), + runtimeSyncMode: null, + }; + } + if (graph.contextGraphId === graphs[1]!.contextGraphId) { + return edgeExact(graph, graph.finalSnapshot, 'edge-auto-always-on'); + } + return edgeAbsent(graph.contextGraphId); + }); + evidence.edge.afterSecondOnDemand = largeCorpus.graphs.map((graph) => { + if (graph.contextGraphId === graphs[0]!.contextGraphId) { + return edgeExact(graph, graph.finalSnapshot, 'edge-second-on-demand'); + } + if (graph.contextGraphId === graphs[1]!.contextGraphId) { + return edgeExact(graph, graph.finalSnapshot, 'edge-auto-always-on'); + } + return edgeAbsent(graph.contextGraphId); + }); + const publicGraphs = largeCorpus.graphs.filter((graph) => graph.accessPolicy === 0); + const chunks = [publicGraphs.slice(0, 32), publicGraphs.slice(32)]; + evidence.core.automaticBatchSize = 32; + evidence.core.rounds = chunks.map((chunk, round) => ({ + round, + jobId: `core-large-${round}`, + planningLane: 'publisher-peer', + source: 'automatic-core-public', + configuredBatchSize: 32, + explicitSelectedContextGraphIds: [], + contextGraphIds: chunk.map((graph) => graph.contextGraphId), + completions: chunk.map((graph) => ({ + contextGraphId: graph.contextGraphId, + completedWave: 'final', + completedSnapshot: graph.finalSnapshot, + })), + })); + evidence.automaticJournalEvidence.coreRounds = chunks.map((chunk, round) => + coreJournal( + round, + `core-large-${round}`, + chunk.map((graph) => graph.contextGraphId), + 32, + )); + const jobByGraph = new Map(chunks.flatMap((chunk, round) => + chunk.map((graph) => [graph.contextGraphId, `core-large-${round}`] as const))); + evidence.core.final = largeCorpus.graphs.map((graph) => ({ + ...(graph.accessPolicy === 0 ? exact(graph, graph.finalSnapshot) : absent(graph.contextGraphId)), + automaticJobIds: graph.accessPolicy === 0 ? [jobByGraph.get(graph.contextGraphId)!] : [], + })); + + const verdict = verifyWithProvenance(evidence, { + ...EXPECTED_PROVENANCE, + corpusManifestDigest: largeCorpus.manifestDigest, + }); + assert.equal(verdict.pass, true, verdict.rejectReasons.join('; ')); +}); + +test('rejects a batch that cannot fit one untruncated journal entry', () => { + assert.throws(() => createSelectiveCoverageCorpus({ + networkId: corpus.networkId, + coreAutomaticBatchSize: 33, + coreCoverageRoundLimit: 1, + graphs, + }), /exceeds one bounded journal entry/); +}); + +test('scheduled Core IDs require exact same-round terminal completions', () => { + const evidence = clone(); + evidence.core.rounds[0].completions.pop(); + evidence.automaticJournalEvidence.coreRounds[0].snapshot.entries[0].completions.pop(); + + const deferred = graphs[1]!; + evidence.core.rounds[1].contextGraphIds.unshift(deferred.contextGraphId); + evidence.core.rounds[1].completions.unshift({ + contextGraphId: deferred.contextGraphId, + completedWave: 'final', + completedSnapshot: deferred.finalSnapshot, + }); + const later = evidence.automaticJournalEvidence.coreRounds[1].snapshot.entries[0]; + later.automaticContextGraphIds.unshift(deferred.contextGraphId); + later.automaticContextGraphCount += 1; + later.completions.unshift({ + jobId: 'core-auto-1', + contextGraphId: deferred.contextGraphId, + state: 'complete', + verified: { metadata: true, durable: true, sharedMemory: true }, + finishedAt: 22, + }); + evidence.core.final[1].automaticJobIds = ['core-auto-1']; + + const verdict = verifySelectiveCoverage(evidence); + assert.equal(verdict.checks.coreAutomaticProvenance, false); + assert.equal(verdict.pass, false); +}); + +test('corpus and evidence serialization is deterministic', () => { + const canonical = canonicalJson({ z: { second: 2, first: 1 }, a: ['@', ':', '/'] }); + assert.equal( + canonical, + canonicalJson({ a: ['@', ':', '/'], z: { first: 1, second: 2 } }), + ); + assert.equal(canonical, '{"a":["@",":","/"],"z":{"first":1,"second":2}}'); + const rebuilt = createSelectiveCoverageCorpus({ + networkId: corpus.networkId, + coreAutomaticBatchSize: corpus.coreAutomaticBatchSize, + coreCoverageRoundLimit: corpus.coreCoverageRoundLimit, + graphs: [...graphs].reverse(), + }); + assert.equal(rebuilt.manifestDigest, corpus.manifestDigest); + assert.equal(canonicalJson(rebuilt), canonicalJson(corpus)); +}); + +test('corpus, runtime, network, and peer roles require an external trust anchor', () => { + const substituted = clone(); + substituted.corpus.graphs[0].finalSnapshot.vm.assetCount += 1; + substituted.corpus = createSelectiveCoverageCorpus({ + networkId: substituted.corpus.networkId, + coreAutomaticBatchSize: substituted.corpus.coreAutomaticBatchSize, + coreCoverageRoundLimit: substituted.corpus.coreCoverageRoundLimit, + graphs: substituted.corpus.graphs, + }); + let verdict = verifyWithProvenance(substituted, EXPECTED_PROVENANCE); + assert.equal(verdict.checks.corpusDigestMatches, true); + assert.equal(verdict.checks.provenanceMatches, false); + + const wrongRole = clone(); + wrongRole.provenance.corePeerId = 'manual-receiver-peer'; + verdict = verifyWithProvenance(wrongRole, EXPECTED_PROVENANCE); + assert.equal(verdict.checks.provenanceMatches, false); +}); + +test('every public graph must have a real larger second publication wave', () => { + const noOp = clone(); + const graph = noOp.corpus.graphs[2]; + graph.finalSnapshot = structuredClone(graph.selectedSnapshot); + noOp.corpus = createSelectiveCoverageCorpus({ + networkId: noOp.corpus.networkId, + coreAutomaticBatchSize: noOp.corpus.coreAutomaticBatchSize, + coreCoverageRoundLimit: noOp.corpus.coreCoverageRoundLimit, + graphs: noOp.corpus.graphs, + }); + noOp.publisher.final[2] = exact(graph, graph.selectedSnapshot); + noOp.core.final[2] = { + ...exact(graph, graph.selectedSnapshot), + automaticJobIds: ['core-auto-1'], + }; + noOp.core.rounds[1].completions[0].completedSnapshot = graph.selectedSnapshot; + const verdict = verifyWithProvenance(noOp, { + ...EXPECTED_PROVENANCE, + corpusManifestDigest: noOp.corpus.manifestDigest, + }); + assert.equal(verdict.checks.publisherSnapshotsExact, true); + assert.equal(verdict.checks.coreFinalPublicExact, true); + assert.equal(verdict.checks.coreAutomaticProvenance, true); + assert.equal(verdict.checks.publicSecondWaveAdvances, false); +}); + +test('metadata-only responses cannot claim Edge or Core completion', () => { + const edgeMetadataOnly = clone(); + edgeMetadataOnly.edge.afterSelection[0].vm.reportedComplete = true; + edgeMetadataOnly.edge.afterSelection[0].vm.assetCount = 0; + edgeMetadataOnly.edge.afterSelection[0].vm.dataTripleCount = 0; + let verdict = verifySelectiveCoverage(edgeMetadataOnly); + assert.equal(verdict.pass, false); + assert.equal(verdict.checks.edgeSelectedSnapshotsExact, false); + assert.equal(verdict.checks.noMetadataOnlyCompletion, false); + + const coreMetadataOnly = clone(); + coreMetadataOnly.core.final[2].swm.reportedComplete = true; + coreMetadataOnly.core.final[2].swm.assetCount = 0; + coreMetadataOnly.core.final[2].swm.dataTripleCount = 0; + verdict = verifySelectiveCoverage(coreMetadataOnly); + assert.equal(verdict.checks.coreFinalPublicExact, false); + assert.equal(verdict.checks.noMetadataOnlyCompletion, false); +}); + +test('private graphs are excluded from Core rounds and final payload', () => { + const scheduledPrivate = clone(); + scheduledPrivate.core.rounds[1].contextGraphIds.push(graphs[3]!.contextGraphId); + let verdict = verifySelectiveCoverage(scheduledPrivate); + assert.equal(verdict.pass, false); + assert.equal(verdict.checks.coreRoundsPublicOnly, false); + assert.equal(verdict.checks.corePrivateExcluded, false); + + const acquiredPrivate = clone(); + acquiredPrivate.core.final[3] = { + ...exact(graphs[3]!, graphs[3]!.finalSnapshot), + automaticJobIds: ['core-auto-1'], + }; + verdict = verifySelectiveCoverage(acquiredPrivate); + assert.equal(verdict.checks.corePrivateExcluded, false); +}); + +test('automatic Core batch overflow fails even when final convergence is exact', () => { + const raw = clone(); + raw.core.rounds[0].contextGraphIds.push(graphs[2]!.contextGraphId); + const verdict = verifySelectiveCoverage(raw); + assert.equal(verdict.pass, false); + assert.equal(verdict.checks.coreBatchWithinBound, false); + assert.equal(verdict.checks.coreFinalPublicExact, true); +}); + +test('VM or SWM digest mismatch fails exact convergence', () => { + const edgeMismatch = clone(); + edgeMismatch.edge.afterRestart[1].swm.inventoryDigest = hash('wrong-edge-swm'); + let verdict = verifySelectiveCoverage(edgeMismatch); + assert.equal(verdict.checks.edgeAlwaysOnRefreshesAfterRestart, false); + assert.equal(verdict.checks.noMetadataOnlyCompletion, false); + + const coreMismatch = clone(); + coreMismatch.core.final[2].vm.headDigest = hash('wrong-core-vm'); + verdict = verifySelectiveCoverage(coreMismatch); + assert.equal(verdict.checks.coreFinalPublicExact, false); +}); + +test('on-demand does not refresh automatically while always-on must refresh after restart', () => { + const onDemandAdvanced = clone(); + onDemandAdvanced.edge.afterRestart[0] = edgeExact( + graphs[0]!, + graphs[0]!.finalSnapshot, + 'edge-select-on-demand', + ); + assert.equal( + verifySelectiveCoverage(onDemandAdvanced).checks.edgeOnDemandRemainsPointInTime, + false, + ); + + const incorrectlyPersistedOnDemand = clone(); + incorrectlyPersistedOnDemand.edge.afterRestart[0].runtimeSyncMode = 'on-demand'; + assert.equal( + verifySelectiveCoverage(incorrectlyPersistedOnDemand).checks.edgeOnDemandRemainsPointInTime, + false, + ); + + const alwaysOnStale = clone(); + alwaysOnStale.edge.afterRestart[1] = edgeExact( + graphs[1]!, + graphs[1]!.selectedSnapshot, + 'edge-auto-always-on', + ); + assert.equal( + verifySelectiveCoverage(alwaysOnStale).checks.edgeAlwaysOnRefreshesAfterRestart, + false, + ); + + const manualAlwaysOn = clone(); + manualAlwaysOn.edge.operations[2].source = 'user'; + assert.equal( + verifySelectiveCoverage(manualAlwaysOn).checks.edgeOperationProvenance, + false, + ); + + const hiddenOnDemandRefresh = clone(); + hiddenOnDemandRefresh.edge.operations[2] = { + ...hiddenOnDemandRefresh.edge.operations[2], + contextGraphId: graphs[0]!.contextGraphId, + syncMode: 'on-demand', + }; + assert.equal( + verifySelectiveCoverage(hiddenOnDemandRefresh).checks.edgeOperationProvenance, + false, + ); +}); + +test('unselected Edge public payload and missing Core scheduling fail independently', () => { + const edgeLeak = clone(); + edgeLeak.edge.afterRestart[2] = { + ...exact(graphs[2]!, graphs[2]!.finalSnapshot), + runtimeSyncMode: null, + producingJobId: null, + }; + assert.equal(verifySelectiveCoverage(edgeLeak).checks.edgeUnselectedExcluded, false); + + const missingCore = clone(); + missingCore.core.rounds.splice(1, 1); + const verdict = verifySelectiveCoverage(missingCore); + assert.equal(verdict.checks.coreEveryPublicScheduled, false); + assert.deepEqual(verdict.missingCoreContextGraphIds, [graphs[2]!.contextGraphId]); + + const leakedSubscription = clone(); + leakedSubscription.edge.afterRestart[2].runtimeSyncMode = 'always-on'; + assert.equal( + verifySelectiveCoverage(leakedSubscription).checks.edgeUnselectedExcluded, + false, + ); + + const leakedPrivateSubscription = clone(); + leakedPrivateSubscription.edge.afterRestart[3].runtimeSyncMode = 'always-on'; + assert.equal( + verifySelectiveCoverage(leakedPrivateSubscription).checks.edgePrivateExcluded, + false, + ); +}); + +test('Core convergence must bind to automatic jobs with no explicit selections', () => { + const explicit = clone(); + explicit.core.rounds[0].explicitSelectedContextGraphIds = [graphs[0]!.contextGraphId]; + let verdict = verifySelectiveCoverage(explicit); + assert.equal(verdict.checks.coreAutomaticProvenance, false); + + const wrongJob = clone(); + wrongJob.core.final[2].automaticJobIds = ['core-auto-0']; + verdict = verifySelectiveCoverage(wrongJob); + assert.equal(verdict.checks.coreAutomaticProvenance, false); + assert.equal(verdict.checks.coreFinalPublicExact, true); + + const staleCompletion = clone(); + staleCompletion.core.rounds[1].completions[0].completedSnapshot = + graphs[2]!.selectedSnapshot; + verdict = verifySelectiveCoverage(staleCompletion); + assert.equal(verdict.checks.coreAutomaticProvenance, false); + assert.equal(verdict.checks.coreFinalPublicExact, true); +}); + +test('Edge checkpoints bind their exact state to the operation that produced it', () => { + const wrongSelectionJob = clone(); + wrongSelectionJob.edge.afterSelection[0].producingJobId = 'edge-auto-always-on'; + let verdict = verifySelectiveCoverage(wrongSelectionJob); + assert.equal(verdict.checks.edgeOperationProvenance, true); + assert.equal(verdict.checks.edgeSelectedSnapshotsExact, false); + + const falseAutomaticCompletion = clone(); + falseAutomaticCompletion.edge.operations[2].completedSnapshot = + graphs[1]!.selectedSnapshot; + verdict = verifySelectiveCoverage(falseAutomaticCompletion); + assert.equal(verdict.checks.edgeAlwaysOnRefreshesAfterRestart, true); + assert.equal(verdict.checks.edgeOperationProvenance, false); +}); + +test('Edge operations preserve canonical corpus order within each phase', () => { + const reordered = clone(); + [reordered.edge.operations[0], reordered.edge.operations[1]] = + [reordered.edge.operations[1], reordered.edge.operations[0]]; + + const verdict = verifySelectiveCoverage(reordered); + assert.equal(verdict.checks.edgeOperationProvenance, false); + assert.equal(verdict.pass, false); +}); + +test('late eventual coverage fails the deterministic first-admission window', () => { + const starved = clone(); + starved.core.rounds = [ + { + ...starved.core.rounds[0], + contextGraphIds: [graphs[0]!.contextGraphId], + completions: [starved.core.rounds[0].completions[0]], + }, + { + ...starved.core.rounds[1], + contextGraphIds: [graphs[0]!.contextGraphId], + completions: [{ + contextGraphId: graphs[0]!.contextGraphId, + completedWave: 'final', + completedSnapshot: graphs[0]!.finalSnapshot, + }], + }, + { + ...starved.core.rounds[1], + round: 2, + jobId: 'core-auto-2', + contextGraphIds: [graphs[1]!.contextGraphId, graphs[2]!.contextGraphId], + completions: [graphs[1]!, graphs[2]!].map((graph) => ({ + contextGraphId: graph.contextGraphId, + completedWave: 'final', + completedSnapshot: graph.finalSnapshot, + })), + }, + ]; + starved.core.final[0].automaticJobIds = ['core-auto-0']; + starved.core.final[1].automaticJobIds = ['core-auto-2']; + starved.core.final[2].automaticJobIds = ['core-auto-2']; + const verdict = verifySelectiveCoverage(starved); + assert.equal(verdict.checks.coreEveryPublicScheduled, true); + assert.equal(verdict.checks.coreCoverageWithinWindow, false); + assert.equal(verdict.checks.coreBatchWithinBound, true); +}); + +test('unknown keys, duplicate rows, and stale manifest digest fail closed', () => { + const extra = clone(); + extra.edge.afterSelection[0].unverified = true; + assert.equal(verifySelectiveCoverage(extra).checks.schemaWellFormed, false); + + const duplicate = clone(); + duplicate.edge.afterRestart[1].contextGraphId = duplicate.edge.afterRestart[0].contextGraphId; + assert.equal(verifySelectiveCoverage(duplicate).checks.corpusCanonicalOrder, false); + + const staleDigest = clone(); + staleDigest.corpus.graphs[0].finalSnapshot.vm.assetCount += 1; + assert.equal(verifySelectiveCoverage(staleDigest).checks.corpusDigestMatches, false); +}); + +test('non-JSON object topology fails closed without throwing', () => { + const hidden = clone(); + Object.defineProperty(hidden.core, 'hidden', { value: true, enumerable: false }); + assert.equal(verifySelectiveCoverage(hidden).checks.schemaWellFormed, false); + + const extendedArray = clone(); + extendedArray.core.rounds.unverified = true; + assert.equal(verifySelectiveCoverage(extendedArray).checks.schemaWellFormed, false); + + const proxy = Proxy.revocable(fixture(), {}); + proxy.revoke(); + assert.doesNotThrow(() => verifySelectiveCoverage(proxy.proxy)); + assert.equal(verifySelectiveCoverage(proxy.proxy).checks.schemaWellFormed, false); +}); + +test('canonical JSON rejects ambiguous or lossy JavaScript values', () => { + const sparse = new Array(1); + const extended: unknown[] & { extra?: boolean } = [1]; + extended.extra = true; + const symbolKeyed = { visible: true } as Record; + symbolKeyed[Symbol('hidden')] = true; + const hidden = { visible: true }; + Object.defineProperty(hidden, 'hidden', { value: true, enumerable: false }); + const accessor = {}; + Object.defineProperty(accessor, 'value', { enumerable: true, get: () => 1 }); + const shared = { value: true }; + for (const value of [ + sparse, + extended, + symbolKeyed, + hidden, + accessor, + [shared, shared], + Number.MAX_SAFE_INTEGER + 1, + -0, + ]) { + assert.throws(() => canonicalJson(value)); + } +}); diff --git a/devnet/rfc64-m1-selective-coverage/verifier.ts b/devnet/rfc64-m1-selective-coverage/verifier.ts new file mode 100644 index 0000000000..b024490550 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/verifier.ts @@ -0,0 +1,516 @@ +import { + SELECTIVE_COVERAGE_VERDICT_SCHEMA, + computeSelectiveCoverageCorpusDigest, + type CoreAutomaticRoundV1, + type CoreFinalObservationV1, + type EdgeGraphObservationV1, + type EdgeSyncOperationV1, + type ExpectedSelectiveCoverageProvenanceV1, + type GraphObservationV1, + type GraphSnapshotExpectationV1, + type PlaneExpectationV1, + type PlaneObservationV1, + type SelectiveCoverageChecksV1, + type SelectiveCoverageCorpusV1, + type SelectiveCoverageEvidenceV1, + type SelectiveCoverageGraphV1, + type SelectiveCoverageVerdictV1, +} from './manifest.ts'; +import { + assertCoreAutomaticRoundJournalV1, + assertEdgeReconcilerJournalV1, +} from './sync-coverage-journal.ts'; +import { + buildEdgeOperationPlan, + findEdgeOperationPlanStep, + matchesEdgeOperationPlanStep, + type EdgeOperationPlanV1, +} from './edge-operation-plan.ts'; + +import { + decodeExpectedSelectiveCoverageProvenance, + decodeSelectiveCoverageEvidence, +} from './evidence-codec.ts'; + +const CHECK_NAMES: readonly (keyof SelectiveCoverageChecksV1)[] = Object.freeze([ + 'schemaWellFormed', + 'provenanceMatches', + 'corpusDigestMatches', + 'corpusCanonicalOrder', + 'requiredPolicyCellsPresent', + 'publisherSnapshotsExact', + 'publicSecondWaveAdvances', + 'edgePassiveBeforeSelection', + 'edgeSelectedSnapshotsExact', + 'edgeOnDemandRemainsPointInTime', + 'edgeAlwaysOnRefreshesAfterRestart', + 'edgeOperationProvenance', + 'edgeSecondOnDemandConverges', + 'edgeUnselectedExcluded', + 'edgePrivateExcluded', + 'coreBatchMatchesManifest', + 'coreBatchWithinBound', + 'coreRoundsPublicOnly', + 'coreAutomaticProvenance', + 'coreEveryPublicScheduled', + 'coreCoverageWithinWindow', + 'coreFinalPublicExact', + 'corePrivateExcluded', + 'noMetadataOnlyCompletion', +]); + +const REASONS: Readonly> = Object.freeze({ + schemaWellFormed: 'evidence failed closed structural validation', + provenanceMatches: 'repository, runtime, network, or process-role provenance differs from the external trust anchor', + corpusDigestMatches: 'corpus manifest digest does not match its closed payload', + corpusCanonicalOrder: 'corpus or observation rows are not canonical and unique', + requiredPolicyCellsPresent: 'corpus must include public on-demand, public always-on, public unselected, private open, and private curated cells', + publisherSnapshotsExact: 'Publisher-owned VM or SWM source snapshots differ from the anchored corpus', + publicSecondWaveAdvances: 'one or more public graphs did not produce a distinct, larger second publication wave', + edgePassiveBeforeSelection: 'Edge acquired VM or SWM payload before any user selection', + edgeSelectedSnapshotsExact: 'Edge selection did not produce exact VM and SWM snapshot evidence', + edgeOnDemandRemainsPointInTime: 'on-demand Edge state changed without another explicit request', + edgeAlwaysOnRefreshesAfterRestart: 'always-on Edge state did not reach the final post-restart snapshot', + edgeOperationProvenance: 'Edge operations do not prove explicit selection, automatic always-on restart work, and no hidden on-demand refresh', + edgeSecondOnDemandConverges: 'a second explicit on-demand request did not reach the final exact snapshot', + edgeUnselectedExcluded: 'Edge acquired an unselected public context graph', + edgePrivateExcluded: 'Edge acquired an unselected private context graph', + coreBatchMatchesManifest: 'Core evidence used a different automatic batch bound', + coreBatchWithinBound: 'a Core automatic round exceeded its configured batch bound', + coreRoundsPublicOnly: 'a Core automatic round admitted a private, duplicate, or unknown context graph', + coreAutomaticProvenance: 'Core rounds or final observations are not bound to scheduler-issued automatic jobs with no explicit selections', + coreEveryPublicScheduled: 'one or more public context graphs never entered a Core automatic round', + coreCoverageWithinWindow: 'Core did not first admit every public graph within the anchored coverage-round limit', + coreFinalPublicExact: 'Core did not converge every public VM and SWM plane exactly', + corePrivateExcluded: 'Core automatic coverage acquired private VM or SWM payload', + noMetadataOnlyCompletion: 'a required plane reported completion without exact nonzero payload', +}); + +/** Verify untrusted harness JSON without trusting summary or synced flags. */ +export function verifySelectiveCoverage( + input: unknown, + expected: ExpectedSelectiveCoverageProvenanceV1, +): SelectiveCoverageVerdictV1 { + try { + if (!decodeExpectedSelectiveCoverageProvenance(expected)) return schemaReject(); + const evidence = decodeSelectiveCoverageEvidence(input); + if (!evidence) return schemaReject(); + return verifyParsed(evidence, expected); + } catch { + return schemaReject(); + } +} + +function verifyParsed( + evidence: SelectiveCoverageEvidenceV1, + expected: ExpectedSelectiveCoverageProvenanceV1, +): SelectiveCoverageVerdictV1 { + const context = buildVerificationContext(evidence, expected); + const envelope = verifyEnvelope(context); + const publisher = verifyPublisher(context); + const edge = verifyEdge(context); + const core = verifyCore(context); + const checks = Object.freeze({ + schemaWellFormed: true, + ...envelope, + ...publisher, + ...edge, + ...core.checks, + noMetadataOnlyCompletion: verifyExactPayloads(context), + }) satisfies SelectiveCoverageChecksV1; + const rejectReasons = CHECK_NAMES.filter((name) => !checks[name]).map((name) => REASONS[name]); + return Object.freeze({ + schema: SELECTIVE_COVERAGE_VERDICT_SCHEMA, + pass: CHECK_NAMES.every((name) => checks[name]), + checks, + missingCoreContextGraphIds: core.missingContextGraphIds, + rejectReasons: Object.freeze(rejectReasons), + recomputedCorpusDigest: computeSelectiveCoverageCorpusDigest(context.corpus), + }); +} + +interface VerificationContext { + readonly evidence: SelectiveCoverageEvidenceV1; + readonly expected: ExpectedSelectiveCoverageProvenanceV1; + readonly corpus: SelectiveCoverageCorpusV1; + readonly graphIds: readonly string[]; + readonly publicGraphs: readonly SelectiveCoverageGraphV1[]; + readonly privateGraphs: readonly SelectiveCoverageGraphV1[]; + readonly byId: ReadonlyMap; + readonly publisherSelected: ReadonlyMap; + readonly publisherFinal: ReadonlyMap; + readonly edgeBefore: ReadonlyMap; + readonly edgeSelected: ReadonlyMap; + readonly edgeRestarted: ReadonlyMap; + readonly edgeSecondOnDemand: ReadonlyMap; + readonly coreFinal: ReadonlyMap; +} + +function buildVerificationContext( + evidence: SelectiveCoverageEvidenceV1, + expected: ExpectedSelectiveCoverageProvenanceV1, +): VerificationContext { + const corpus = evidence.corpus; + return { + evidence, + expected, + corpus, + graphIds: corpus.graphs.map((graph) => graph.contextGraphId), + publicGraphs: corpus.graphs.filter((graph) => graph.accessPolicy === 0), + privateGraphs: corpus.graphs.filter((graph) => graph.accessPolicy === 1), + byId: new Map(corpus.graphs.map((graph) => [graph.contextGraphId, graph])), + publisherSelected: byObservationId(evidence.publisher.selected), + publisherFinal: byObservationId(evidence.publisher.final), + edgeBefore: byObservationId(evidence.edge.beforeSelection), + edgeSelected: byObservationId(evidence.edge.afterSelection), + edgeRestarted: byObservationId(evidence.edge.afterRestart), + edgeSecondOnDemand: byObservationId(evidence.edge.afterSecondOnDemand), + coreFinal: byObservationId(evidence.core.final), + }; +} + +function verifyEnvelope(context: VerificationContext) { + const { evidence, expected, corpus, graphIds } = context; + const observationsCanonical = [ + evidence.publisher.selected, + evidence.publisher.final, + evidence.edge.beforeSelection, + evidence.edge.afterSelection, + evidence.edge.afterRestart, + evidence.edge.afterSecondOnDemand, + evidence.core.final, + ].every((rows) => exactCanonicalIds(rows.map((row) => row.contextGraphId), graphIds)); + return { + provenanceMatches: evidence.provenance.networkId === expected.networkId + && evidence.provenance.testedHeadCommit === expected.testedHeadCommit + && evidence.provenance.runtimeManifestDigest === expected.runtimeManifestDigest + && evidence.provenance.publisherPeerId === expected.publisherPeerId + && evidence.provenance.edgePeerId === expected.edgePeerId + && evidence.provenance.corePeerId === expected.corePeerId + && corpus.networkId === expected.networkId + && corpus.manifestDigest === expected.corpusManifestDigest, + corpusDigestMatches: computeSelectiveCoverageCorpusDigest(corpus) === corpus.manifestDigest, + corpusCanonicalOrder: strictlyIncreasing(graphIds) && observationsCanonical, + requiredPolicyCellsPresent: hasRequiredPolicyCells(corpus.graphs), + }; +} + +function verifyPublisher(context: VerificationContext) { + return { + publisherSnapshotsExact: context.corpus.graphs.every((graph) => + exactGraph(context.publisherSelected.get(graph.contextGraphId), graph.selectedSnapshot) + && exactGraph(context.publisherFinal.get(graph.contextGraphId), graph.finalSnapshot)), + publicSecondWaveAdvances: context.publicGraphs.every((graph) => + snapshotsAdvance(graph.selectedSnapshot, graph.finalSnapshot)), + }; +} + +function verifyEdge(context: VerificationContext) { + const { corpus, evidence, edgeBefore, edgeSelected, edgeRestarted, + edgeSecondOnDemand } = context; + const operationPlan = buildEdgeOperationPlan(corpus); + const edgePassiveBeforeSelection = corpus.graphs.every((graph) => + absentGraph(edgeBefore.get(graph.contextGraphId)) + && edgeBefore.get(graph.contextGraphId)?.runtimeSyncMode === null + && edgeBefore.get(graph.contextGraphId)?.producingJobId === null); + const edgeSelectedSnapshotsExact = corpus.graphs.every((graph) => { + const observed = edgeSelected.get(graph.contextGraphId); + return graph.accessPolicy === 0 && graph.edgePolicy !== 'unselected' + ? exactGraph(observed, graph.selectedSnapshot) + && observed?.runtimeSyncMode === graph.edgePolicy + && observed.producingJobId + === edgeJobId(operationPlan, evidence.edge.operations, graph.contextGraphId, 'selection') + : absentGraph(observed) && observed?.runtimeSyncMode === null + && observed.producingJobId === null; + }); + const edgeOnDemandRemainsPointInTime = corpus.graphs + .filter((graph) => graph.edgePolicy === 'on-demand') + .every((graph) => exactGraph(edgeRestarted.get(graph.contextGraphId), graph.selectedSnapshot) + && edgeRestarted.get(graph.contextGraphId)?.runtimeSyncMode === null + && edgeRestarted.get(graph.contextGraphId)?.producingJobId + === edgeJobId(operationPlan, evidence.edge.operations, graph.contextGraphId, 'selection')); + const edgeAlwaysOnRefreshesAfterRestart = corpus.graphs + .filter((graph) => graph.edgePolicy === 'always-on') + .every((graph) => snapshotsAdvance(graph.selectedSnapshot, graph.finalSnapshot) + && exactGraph(edgeRestarted.get(graph.contextGraphId), graph.finalSnapshot) + && edgeRestarted.get(graph.contextGraphId)?.runtimeSyncMode === 'always-on' + && edgeRestarted.get(graph.contextGraphId)?.producingJobId + === edgeJobId( + operationPlan, + evidence.edge.operations, + graph.contextGraphId, + 'post-restart-auto', + )); + const edgeSecondOnDemandConverges = corpus.graphs.every((graph) => { + const observed = edgeSecondOnDemand.get(graph.contextGraphId); + if (graph.accessPolicy !== 0 || graph.edgePolicy === 'unselected') { + return absentGraph(observed) && observed?.runtimeSyncMode === null + && observed.producingJobId === null; + } + const phase = graph.edgePolicy === 'always-on' + ? 'post-restart-auto' + : 'post-restart-explicit'; + return snapshotsAdvance(graph.selectedSnapshot, graph.finalSnapshot) + && exactGraph(observed, graph.finalSnapshot) + && observed?.runtimeSyncMode === graph.edgePolicy + && observed.producingJobId + === edgeJobId(operationPlan, evidence.edge.operations, graph.contextGraphId, phase); + }); + const excluded = (graph: SelectiveCoverageGraphV1) => + absentGraph(edgeSelected.get(graph.contextGraphId)) + && absentGraph(edgeRestarted.get(graph.contextGraphId)) + && absentGraph(edgeSecondOnDemand.get(graph.contextGraphId)) + && [edgeSelected, edgeRestarted, edgeSecondOnDemand].every((phase) => + phase.get(graph.contextGraphId)?.runtimeSyncMode === null + && phase.get(graph.contextGraphId)?.producingJobId === null); + return { + edgePassiveBeforeSelection, + edgeSelectedSnapshotsExact, + edgeOnDemandRemainsPointInTime, + edgeAlwaysOnRefreshesAfterRestart, + edgeOperationProvenance: verifyEdgeOperations(evidence.edge.operations, operationPlan) + && verifyAutomaticEdgeJournals(evidence), + edgeSecondOnDemandConverges, + edgeUnselectedExcluded: context.publicGraphs + .filter((graph) => graph.edgePolicy === 'unselected').every(excluded), + edgePrivateExcluded: context.privateGraphs.every(excluded), + }; +} + +function verifyCore(context: VerificationContext) { + const { evidence, expected, corpus, byId, coreFinal } = context; + const automaticJobs = new Map(evidence.core.rounds.map((round) => [round.jobId, round])); + const scheduled = new Set(evidence.core.rounds.flatMap((round) => round.contextGraphIds)); + const missingContextGraphIds = Object.freeze(context.publicGraphs + .map((graph) => graph.contextGraphId) + .filter((contextGraphId) => !scheduled.has(contextGraphId))); + const scheduledWithinWindow = new Set(evidence.core.rounds + .slice(0, corpus.coreCoverageRoundLimit) + .flatMap((round) => round.contextGraphIds)); + const coreAutomaticProvenance = automaticJobs.size === evidence.core.rounds.length + && verifyAutomaticCoreJournals(evidence) + && evidence.core.rounds.every((round) => + round.source === 'automatic-core-public' + && round.planningLane === expected.publisherPeerId + && round.configuredBatchSize === corpus.coreAutomaticBatchSize + && round.explicitSelectedContextGraphIds.length === 0 + && round.completions.length === round.contextGraphIds.length + && new Set(round.completions.map((completion) => completion.contextGraphId)).size + === round.completions.length + && round.completions.every((completion, index) => { + const graph = byId.get(completion.contextGraphId); + return round.contextGraphIds[index] === completion.contextGraphId + && completion.completedWave === 'final' + && graph?.accessPolicy === 0 + && exactSnapshot(completion.completedSnapshot, graph.finalSnapshot); + })) + && evidence.core.final.every((observation) => { + const graph = byId.get(observation.contextGraphId); + if (graph?.accessPolicy !== 0) return observation.automaticJobIds.length === 0; + return observation.automaticJobIds.length > 0 + && observation.automaticJobIds.every((jobId) => + automaticJobs.get(jobId)?.contextGraphIds.includes(observation.contextGraphId) === true) + && observation.automaticJobIds.some((jobId) => + automaticJobs.get(jobId)?.completions.some((completion) => + completion.contextGraphId === observation.contextGraphId + && exactSnapshot(completion.completedSnapshot, graph.finalSnapshot))); + }); + return { + checks: { + coreBatchMatchesManifest: evidence.core.automaticBatchSize + === corpus.coreAutomaticBatchSize, + coreBatchWithinBound: evidence.core.rounds.every((round) => + round.contextGraphIds.length <= corpus.coreAutomaticBatchSize), + coreRoundsPublicOnly: evidence.core.rounds.every((round) => + new Set(round.contextGraphIds).size === round.contextGraphIds.length + && round.contextGraphIds.every((id) => byId.get(id)?.accessPolicy === 0)), + coreAutomaticProvenance, + coreEveryPublicScheduled: missingContextGraphIds.length === 0, + coreCoverageWithinWindow: context.publicGraphs.every((graph) => + scheduledWithinWindow.has(graph.contextGraphId)), + coreFinalPublicExact: context.publicGraphs.every((graph) => + exactGraph(coreFinal.get(graph.contextGraphId), graph.finalSnapshot)), + corePrivateExcluded: context.privateGraphs.every((graph) => + absentGraph(coreFinal.get(graph.contextGraphId)) && !scheduled.has(graph.contextGraphId)), + }, + missingContextGraphIds, + }; +} + +function verifyExactPayloads(context: VerificationContext): boolean { + const required: Array = []; + for (const graph of context.corpus.graphs) { + if (graph.accessPolicy === 0 && graph.edgePolicy !== 'unselected') { + const selected = context.edgeSelected.get(graph.contextGraphId); + required.push([selected?.vm, graph.selectedSnapshot.vm], [selected?.swm, graph.selectedSnapshot.swm]); + const restarted = context.edgeRestarted.get(graph.contextGraphId); + const expected = graph.edgePolicy === 'always-on' ? graph.finalSnapshot : graph.selectedSnapshot; + required.push([restarted?.vm, expected.vm], [restarted?.swm, expected.swm]); + const final = context.edgeSecondOnDemand.get(graph.contextGraphId); + required.push([final?.vm, graph.finalSnapshot.vm], [final?.swm, graph.finalSnapshot.swm]); + } + if (graph.accessPolicy === 0) { + const final = context.coreFinal.get(graph.contextGraphId); + required.push([final?.vm, graph.finalSnapshot.vm], [final?.swm, graph.finalSnapshot.swm]); + } + } + return required.every(([observed, expected]) => + exactPlane(observed, expected) && (observed?.dataTripleCount ?? 0) > 0); +} + +function hasRequiredPolicyCells(graphs: readonly SelectiveCoverageGraphV1[]): boolean { + return graphs.some((graph) => graph.accessPolicy === 0 && graph.edgePolicy === 'on-demand') + && graphs.some((graph) => graph.accessPolicy === 0 && graph.edgePolicy === 'always-on') + && graphs.some((graph) => graph.accessPolicy === 0 && graph.edgePolicy === 'unselected') + && graphs.some((graph) => graph.accessPolicy === 0 && graph.publishPolicy === 1) + && graphs.some((graph) => graph.accessPolicy === 0 && graph.publishPolicy === 0) + && graphs.some((graph) => graph.accessPolicy === 1 && graph.publishPolicy === 1) + && graphs.some((graph) => graph.accessPolicy === 1 && graph.publishPolicy === 0); +} + +function verifyAutomaticEdgeJournals(evidence: SelectiveCoverageEvidenceV1): boolean { + const operations = evidence.edge.operations.filter((operation) => + operation.phase === 'post-restart-auto'); + if (operations.length !== evidence.automaticJournalEvidence.edgeReconciler.length) { + return false; + } + try { + operations.forEach((operation, index) => assertEdgeReconcilerJournalV1( + evidence.automaticJournalEvidence.edgeReconciler[index], + operation, + evidence.automaticJournalEvidence.edgeProcess, + )); + return true; + } catch { + return false; + } +} + +function verifyAutomaticCoreJournals(evidence: SelectiveCoverageEvidenceV1): boolean { + if (evidence.core.rounds.length !== evidence.automaticJournalEvidence.coreRounds.length) { + return false; + } + try { + evidence.core.rounds.forEach((round, index) => assertCoreAutomaticRoundJournalV1( + evidence.automaticJournalEvidence.coreRounds[index], + round, + evidence.automaticJournalEvidence.coreProcess, + )); + return true; + } catch { + return false; + } +} + +function verifyEdgeOperations( + operations: readonly EdgeSyncOperationV1[], + plan: EdgeOperationPlanV1, +): boolean { + if (operations.length !== plan.ordered.length + || new Set(operations.map((operation) => operation.jobId)).size !== operations.length) { + return false; + } + return plan.ordered.every((step, index) => + matchesEdgeOperationPlanStep(operations[index], step)); +} + +function edgeJobId( + plan: EdgeOperationPlanV1, + operations: readonly EdgeSyncOperationV1[], + contextGraphId: string, + phase: EdgeSyncOperationV1['phase'], +): string | undefined { + const step = findEdgeOperationPlanStep(plan, contextGraphId, phase); + if (!step) return undefined; + const operation = operations[step.sequence]; + return operation?.sequence === step.sequence + && operation.phase === step.phase + && operation.contextGraphId === step.contextGraphId + ? operation.jobId + : undefined; +} + +function exactGraph( + observed: GraphObservationV1 | undefined, + expected: GraphSnapshotExpectationV1, +): boolean { + return exactPlane(observed?.vm, expected.vm) && exactPlane(observed?.swm, expected.swm); +} + +function exactPlane( + observed: PlaneObservationV1 | undefined, + expected: PlaneExpectationV1, +): boolean { + return observed?.reportedComplete === true + && observed.headDigest === expected.headDigest + && observed.inventoryDigest === expected.inventoryDigest + && observed.assetCount === expected.assetCount + && observed.metadataTripleCount > 0 + && observed.dataTripleCount === expected.dataTripleCount; +} + +function absentGraph(observed: GraphObservationV1 | undefined): boolean { + return absentPlane(observed?.vm) && absentPlane(observed?.swm); +} + +function absentPlane(observed: PlaneObservationV1 | undefined): boolean { + return observed?.reportedComplete === false + && observed.headDigest === null + && observed.inventoryDigest === null + && observed.assetCount === 0 + && observed.dataTripleCount === 0; +} + +function exactSnapshot( + left: GraphSnapshotExpectationV1, + right: GraphSnapshotExpectationV1, +): boolean { + return exactExpectation(left.vm, right.vm) && exactExpectation(left.swm, right.swm); +} + +function exactExpectation(left: PlaneExpectationV1, right: PlaneExpectationV1): boolean { + return left.headDigest === right.headDigest + && left.inventoryDigest === right.inventoryDigest + && left.assetCount === right.assetCount + && left.dataTripleCount === right.dataTripleCount; +} + +function snapshotsAdvance( + left: GraphSnapshotExpectationV1, + right: GraphSnapshotExpectationV1, +): boolean { + return left.vm.headDigest !== right.vm.headDigest + && left.vm.inventoryDigest !== right.vm.inventoryDigest + && left.swm.headDigest !== right.swm.headDigest + && left.swm.inventoryDigest !== right.swm.inventoryDigest + && right.vm.assetCount > left.vm.assetCount + && right.vm.dataTripleCount > left.vm.dataTripleCount + && right.swm.assetCount > left.swm.assetCount + && right.swm.dataTripleCount > left.swm.dataTripleCount; +} + +function byObservationId(rows: readonly T[]): Map { + return new Map(rows.map((row) => [row.contextGraphId, row])); +} + +function exactCanonicalIds(actual: readonly string[], expected: readonly string[]): boolean { + return actual.length === expected.length && actual.every((id, index) => id === expected[index]); +} + +function strictlyIncreasing(values: readonly string[]): boolean { + return values.every((value, index) => index === 0 || values[index - 1]! < value); +} + +function schemaReject(): SelectiveCoverageVerdictV1 { + const checks = Object.freeze(Object.fromEntries( + CHECK_NAMES.map((name) => [name, false]), + )) as Readonly; + return Object.freeze({ + schema: SELECTIVE_COVERAGE_VERDICT_SCHEMA, + pass: false, + checks, + missingCoreContextGraphIds: Object.freeze([]), + rejectReasons: Object.freeze([REASONS.schemaWellFormed]), + recomputedCorpusDigest: '', + }); +} diff --git a/devnet/rfc64-m1-selective-coverage/verify-live.ts b/devnet/rfc64-m1-selective-coverage/verify-live.ts new file mode 100644 index 0000000000..dfa66583b1 --- /dev/null +++ b/devnet/rfc64-m1-selective-coverage/verify-live.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { readCleanRepositoryHead } from '../rfc64-persistence-lifecycle/evidence.ts'; +import { buildGate2RuntimeManifestV1 } from + '../rfc64-gate2-multi-asset-completeness/runtime-provenance.ts'; +import { readExpectedSelectiveCoverageProvenance } from './operator-input.ts'; +import { verifySelectiveCoverage } from './verifier.ts'; + +const repoRoot = resolve(import.meta.dirname, '../..'); +const trustAnchorPath = resolve(requiredEnvironment('DKG_RFC64_M1_TRUST_ANCHOR_FILE')); +const artifactPath = resolve( + process.env['DKG_RFC64_M1_ARTIFACT'] + ?? resolve(import.meta.dirname, 'artifacts/selective-coverage-evidence.json'), +); +const expected = readExpectedSelectiveCoverageProvenance(trustAnchorPath); +const sourceCommit = readCleanRepositoryHead(repoRoot); +if (sourceCommit !== expected.testedHeadCommit) { + throw new Error('M1 trust anchor names a different checked-out source commit'); +} +const runtimeManifest = buildGate2RuntimeManifestV1(repoRoot, sourceCommit); +if (runtimeManifest.manifestDigest !== expected.runtimeManifestDigest) { + throw new Error('M1 trust anchor names a different built runtime manifest'); +} +const evidence = JSON.parse(readFileSync(artifactPath, 'utf8')); +const verdict = verifySelectiveCoverage(evidence, expected); +if (!verdict.pass) { + throw new Error(`M1 evidence rejected: ${verdict.rejectReasons.join('; ')}`); +} +process.stdout.write(`[rfc64-m1] VERIFIED ${artifactPath}\n`); + +function requiredEnvironment(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} diff --git a/devnet/rfc64-persistence-lifecycle/evidence.test.ts b/devnet/rfc64-persistence-lifecycle/evidence.test.ts index 5b83914278..7993042b6b 100644 --- a/devnet/rfc64-persistence-lifecycle/evidence.test.ts +++ b/devnet/rfc64-persistence-lifecycle/evidence.test.ts @@ -19,6 +19,7 @@ import test from 'node:test'; import { atomicWriteStableJson, + compactSafeIntegerJson, readCleanRepositoryHead, stableJson, } from './evidence.js'; @@ -31,7 +32,7 @@ test.afterEach(() => { } }); -test('stableJson sorts keys and preserves supported plain data exactly', () => { +test('stable JSON encoders preserve their distinct exact policies', () => { const value = { z: [true, null, 17.25, 'plain'], a: { second: 2, first: 1 }, @@ -42,6 +43,15 @@ test('stableJson sorts keys and preserves supported plain data exactly', () => { '{\n "a": {\n "first": 1,\n "second": 2\n },\n "z": [\n true,\n null,\n 17.25,\n "plain"\n ]\n}\n', ); assert.deepEqual(JSON.parse(encoded), value); + assert.equal( + compactSafeIntegerJson({ z: [true, null, 17], a: { second: 2, first: 1 } }), + '{"a":{"first":1,"second":2},"z":[true,null,17]}', + ); + assert.throws(() => compactSafeIntegerJson({ value: 17.25 }), /non-lossless JSON number/u); + assert.throws( + () => compactSafeIntegerJson({ value: Number.MAX_SAFE_INTEGER + 1 }), + /non-lossless JSON number/u, + ); }); test('stableJson rejects values that JSON would omit, coerce, or reshape', () => { diff --git a/devnet/rfc64-persistence-lifecycle/evidence.ts b/devnet/rfc64-persistence-lifecycle/evidence.ts index 74d513f463..04ee0c5cc0 100644 --- a/devnet/rfc64-persistence-lifecycle/evidence.ts +++ b/devnet/rfc64-persistence-lifecycle/evidence.ts @@ -57,11 +57,26 @@ export function readCleanRepositoryHead(repoRootInput: string): string { return head; } +type JsonNumberPolicy = 'finite' | 'safe-integer'; + +/** Gate 0 canonical JSON: sorted keys, two-space indentation, and one trailing LF. */ export function stableJson(value: unknown): string { - const normalized = normalizePlainJsonValue(value, '$', new WeakSet()); + const normalized = normalizePlainJsonValue( + value, + '$', + new WeakSet(), + 'finite', + ); return `${JSON.stringify(normalized, null, 2)}\n`; } +/** M1 canonical JSON: sorted keys, compact bytes, and lossless integer numbers only. */ +export function compactSafeIntegerJson(value: unknown): string { + return JSON.stringify( + normalizePlainJsonValue(value, '$', new WeakSet(), 'safe-integer'), + ); +} + export function atomicWriteStableJson( artifactPathInput: string, value: unknown, @@ -143,12 +158,14 @@ function normalizePlainJsonValue( value: unknown, path: string, seen: WeakSet, + numbers: JsonNumberPolicy, ): unknown { if (value === null || typeof value === 'string' || typeof value === 'boolean') { return value; } if (typeof value === 'number') { - if (!Number.isFinite(value) || Object.is(value, -0)) { + if (!Number.isFinite(value) || Object.is(value, -0) + || (numbers === 'safe-integer' && !Number.isSafeInteger(value))) { throw new TypeError(`${path} contains a non-lossless JSON number`); } return value; @@ -184,7 +201,7 @@ function normalizePlainJsonValue( if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError(`${path}[${index}] is not an enumerable data property`); } - return normalizePlainJsonValue(descriptor.value, `${path}[${index}]`, seen); + return normalizePlainJsonValue(descriptor.value, `${path}[${index}]`, seen, numbers); }); } @@ -202,7 +219,7 @@ function normalizePlainJsonValue( if (descriptor === undefined || !descriptor.enumerable || !('value' in descriptor)) { throw new TypeError(`${path}.${key} is not an enumerable data property`); } - return [key, normalizePlainJsonValue(descriptor.value, `${path}.${key}`, seen)]; + return [key, normalizePlainJsonValue(descriptor.value, `${path}.${key}`, seen, numbers)]; }) .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); return Object.fromEntries(entries); diff --git a/package.json b/package.json index c898db7014..5a68f4e6d2 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,11 @@ "test:m1:rfc64-public-swm-parity:verify": "node --import tsx devnet/rfc64-cp1-public-swm-parity/verify-live.ts", "test:m1:rfc64-public-swm-parity:unit": "node --import tsx --test devnet/rfc64-cp1-public-swm-parity/verifier.test.ts", "typecheck:m1:rfc64-public-swm-parity": "tsc --project devnet/rfc64-cp1-public-swm-parity/tsconfig.json", + "test:m1:rfc64-selective-coverage": "pnpm run test:m1:rfc64-selective-coverage:generate && pnpm run test:m1:rfc64-selective-coverage:verify", + "test:m1:rfc64-selective-coverage:generate": "node --import tsx devnet/rfc64-m1-selective-coverage/launch-live.ts", + "test:m1:rfc64-selective-coverage:verify": "node --import tsx devnet/rfc64-m1-selective-coverage/verify-live.ts", + "test:m1:rfc64-selective-coverage:unit": "node --experimental-strip-types --test devnet/rfc64-m1-selective-coverage/verifier.test.ts devnet/rfc64-m1-selective-coverage/runtime.test.ts devnet/rfc64-m1-selective-coverage/process-runtime.test.ts devnet/rfc64-m1-selective-coverage/adapter-environment.test.ts devnet/rfc64-m1-selective-coverage/operator-input.test.ts devnet/rfc64-m1-selective-coverage/boundary-codec.test.ts", + "typecheck:m1:rfc64-selective-coverage": "tsc --project devnet/rfc64-m1-selective-coverage/tsconfig.json", "test:devnet:v10-core-flows": "vitest run --config devnet/v10-core-flows/vitest.config.ts", "test:devnet:v10-e2e": "vitest run --config devnet/v10-end-to-end/vitest.config.ts", "test:devnet:v10-stress": "vitest run --config devnet/v10-stress/vitest.config.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b40b2e451..07e1e795a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,8 @@ importers: devnet/rfc64-m0-public-baseline: {} + devnet/rfc64-m1-selective-coverage: {} + devnet/rfc64-persistence-lifecycle: dependencies: '@origintrail-official/dkg-agent': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aba8272f79..5d9f97e88a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,7 @@ packages: - "devnet/rfc64-gate1-public-open" - "devnet/rfc64-gate2-multi-asset-completeness" - "devnet/rfc64-cp1-public-swm-parity" + - "devnet/rfc64-m1-selective-coverage" - "devnet/rfc64-m0-public-baseline" - "devnet/pr1386-term-canon" - "devnet/pr1385-subgraph-rs"