diff --git a/docs/durable-graph-engine-continuation-plan.md b/docs/durable-graph-engine-continuation-plan.md new file mode 100644 index 000000000..61bff2891 --- /dev/null +++ b/docs/durable-graph-engine-continuation-plan.md @@ -0,0 +1,366 @@ +# Durable Graph Engine Continuation — Phase 2 + +## North Star and stop condition + +Phase 1 made a quiescent Graph v2 runtime-event prefix portable. Phase 2 must +connect that proof to the real scheduler: after the producing process exits, a +new OS process accepts the JSON checkpoint, runs one changed catalog owner and +only the affected keyed closure, and returns the next portable checkpoint. That +is the smallest causal proof that durable graph evidence produces incremental +work reuse rather than merely reconstructing an in-memory projection. + +Stop instead of widening the feature if one bounded child-process test cannot +prove same-prefix continuation through the existing journal, claim scheduler, +and provider path. Any need to change `src/snapshot-store.ts`, a reducer, or +scheduler behavior is a stop, as is any need for generic runner serialization, +lease recovery, a second projection format, or process supervision. + +## Measurable outcome + +One implementation PR adds an engine-level continuation operation which: + +- restores an accepted, quiescent `GraphJournalCheckpointV1` in a genuinely + fresh OS process under the exact compiled graph; +- retains the checkpoint session and its complete event prefix, including all + existing instance, claim, node, and generation IDs; +- queues exactly one reconciliation request for one compiled root expansion + owner; +- enters a fresh `StateMachineRunner` at `LevelDispatch`, wave 1, so the owner + runs once and the journal releases only newly ready affected generations; +- never starts a completed unchanged generation; +- appends event IDs, request/root-attempt ordinals, generated attempt IDs, and + global fences monotonically from the restored prefix; and +- returns the `ExecutionResult`, request ID, and a quiescent checkpoint which + can be JSON-round-tripped, restored, and replayed exactly before the test + stops. Phase 2 does not perform a second continuation. + +“Changed catalog” means changed provider output under the same compiled graph, +not a changed `VisorConfig` or `graphSemanticDigest`. There is no separate +journal-ID field to preserve; the durable identity is the exact event prefix, +its session, and the IDs carried by that prefix. + +## Public contract + +Add one narrowly named method to the already SDK-exported +`StateMachineExecutionEngine`: + +```ts +export interface GraphCheckpointContinuationInput { + checkpoint: unknown; + expansionOwnerCheck: string; + config: VisorConfig; + prInfo: PRInfo; + debug?: boolean; + maxParallelism?: number; + failFast?: boolean; +} + +export interface GraphCheckpointContinuationResult { + requestId: string; + result: ExecutionResult; + checkpoint: GraphJournalCheckpointV1; +} + +continueGraphCheckpoint( + input: GraphCheckpointContinuationInput +): Promise; +``` + +The method is intentionally not named `resume`: it performs exactly one root +catalog reconciliation from a completed Graph checkpoint. It does not accept a +serialized `RunState`, requested checks, multiple owners, or a legacy journal. +Export the input/result types and `GraphJournalCheckpointV1` from `src/sdk.ts` +only as type exports; add no second SDK execution wrapper. + +## Production scope + +- `src/state-machine/context/build-engine-context.ts` + - accept a private optional Graph-checkpoint bootstrap; + - compile the supplied config first, restore with + `ExecutionJournal.restoreGraphCheckpoint`, then read the now-validated + checkpoint session and bind both journal and `sessionId`; + - perform that binding before creating `MemoryStore` or the shared + `FairConcurrencyLimiter`, whose closures capture `sessionId`. +- `src/state-machine-execution-engine.ts` + - add the public operation and its private continuation bootstrap; + - place all checkpoint/owner gates before custom-tool registration, memory or + workspace initialization, sandbox/policy/frontend setup, provider execution, + or installation of `_lastContext`/`_lastRunner`; + - reuse the existing post-context setup, runner, and cleanup path rather than + copying a second engine lifecycle; + - seed the fresh runner and export the returned checkpoint after it drains. +- `src/state-machine/runner.ts` + - make the initial observer event a self-transition of the actual seeded + state, not the hard-coded `Init -> Init` event. +- `src/sdk.ts` + - type-only exports for the new engine contract and checkpoint type. + +Test scope: + +- `tests/engine/durable-graph-engine-continuation.test.ts` +- `tests/fixtures/durable-graph-engine-continuation-child.ts` + +Do not change `src/snapshot-store.ts`, the reducers, `wave-planning.ts`, +`level-dispatch.ts`, provider implementations, legacy snapshot functions, or +`RunState`. The `src/sdk.ts` change is type-only; no facade or runtime export +logic is added. In particular, do not add a public “export current checkpoint” +method: the producer fixture uses existing private test access. Any need to +change scheduler/reducer behavior triggers the stop condition and a separate +review rather than scope growth. + +## Restore and fail-fast transaction + +Use this exact order for the continuation branch: + +1. Deep-clone config and compile the exact claim/expansion plan as today. +2. Call `ExecutionJournal.restoreGraphCheckpoint(claimPlan, input.checkpoint)`. + Do not duplicate envelope, integrity, graph, replay, quiescence, or allocator + validation in the engine. +3. Only after restore succeeds, read `sessionId` from the validated checkpoint, + install that journal/session in the local context, and then create any + session-capturing limiter. Never build with a generated session and overwrite + it later. +4. Immediately call the restored journal's + `requestCatalogReconciliation({ sessionId, ownerCheck })` once. This reuses + the compiled-plan owner check and its `UNKNOWN_EXPANSION_OWNER` error and + records the request as the first suffix event. Do not call the public runner + request API, which correctly rejects an inactive runner. +5. Retain the returned `requestId` in the per-call bootstrap result. There must + be no second request insertion in the runner or scheduler. +6. Only after steps 1–5 succeed, initialize `context.memory` explicitly (the + continuation skips `Init`), run `initializeWorkspace(context)`, and perform + the existing custom-tool, sandbox, policy, frontend, and execution-context + setup. +7. Construct a fresh runner, seed only the fixed continuation state below, then + install `_lastContext` and `_lastRunner` immediately before `run()`. +8. Reuse the normal `finally` cleanup for frontends, sessions, policy, workspace, + and sandboxes. Once the runner finishes, export with + `context.journal.exportGraphCheckpoint(context.sessionId)` and return it with + the request ID and execution result. + +The post-context engine lifecycle may be mechanically extracted into one private +helper so normal execution and continuation share setup/cleanup. Its normal-run +branch must be behavior-preserving. Do not introduce an externally visible +optional continuation argument on `executeGroupedChecks`, mutable engine-wide +bootstrap state, or a second copied lifecycle. + +Do not clear `_lastContext` or `_lastRunner` at method entry. Validation and +setup operate on locals; any validation or setup failure leaves both engine +fields exactly as they were before the call. A fresh engine consequently remains +unset and reports `RUN_NOT_ACTIVE`, while an engine with a prior completed run +continues to expose that prior state rather than losing or replacing it. + +On corrupt, graph-mismatched, nonquiescent, or unknown-owner input: + +- preserve the existing exact error code; +- make zero provider calls; +- do not initialize memory or workspace; +- do not register tools or start sandbox, policy, or frontend services; and +- do not mutate `_lastContext` or `_lastRunner`. Fresh-engine failure tests must + observe them unset and see projection/reconciliation access fail with + `RUN_NOT_ACTIVE`; tests must never clear pre-existing engine state to obtain + that result. + +## Fixed runner entry + +Create a new `StateMachineRunner(context)` and derive the continuation state +from its fresh default, changing only: + +```ts +currentState: 'LevelDispatch' +wave: 1 +``` + +Before `setState`, assert/construct the rest of the state as the fresh empty +frontier: empty `levelQueue`, `eventQueue`, dispatch maps, completion sets, +stats/history, routing guards, current-level sets, and pending scopes, with the +normal default flags. This is a fixed engine bootstrap, not deserialization of +arbitrary checkpoint state. + +`WavePlanning` is not a safe entry. At wave 0 it needs a dependency graph and +queues the initial roots, relaunching completed work; at wave 1 with no levels it +can complete without servicing the pending catalog request. `LevelDispatch` +with an empty queue enters the active claim scheduler, sees no ready work in the +quiescent prefix, launches the single pending catalog owner, then drains only +generations made ready by reconciliation. Its transition back to +`WavePlanning` at wave 1 completes normally without reconstructing the original +dependency graph. + +Correct `StateMachineRunner.run()` to emit +`{ type: 'StateTransition', from: currentState, to: currentState }` at entry. +Normal runs still report `Init -> Init`; continuation truthfully reports +`LevelDispatch -> LevelDispatch`. This is only an observer correction. The plan +does not claim that Phase 1 journal checkpoints preserve pre-checkpoint observer +history or restore an observer timeline. + +## Why unchanged work cannot relaunch + +The continuation adds no scheduler policy. Existing authorities supply the +proof: + +- checkpoint restore admits only a quiescent replayed projection; +- reconciliation fingerprints keyed catalog items and leaves identical items' + active completed generations unchanged; +- changed/added/revived items inactivate superseded generations and activate + new generation IDs for the affected compiled template closure; and +- `queryReadyWork()` returns only generations whose replayed status is `ready`. + +Therefore the empty `LevelDispatch` has no root level to replay and no old +completed generation to launch. The test must falsify this with provider-call +and `AttemptStarted` evidence; projection equality alone is insufficient. + +Allocator authority also remains solely in the restored event prefix. The +engine must not copy counters. The first suffix event ID is +`checkpoint.frontier.lastEventId + 1`; the owner request uses the next per-owner +request ordinal; root and catalog attempts continue their shared ordinal; every +new generation starts at generated ordinal 1; and every start consumes the next +global fence reconstructed by Phase 1. + +## Genuine fresh-process fixture + +The focused Jest file invokes the fixture with `execFile`/`spawnSync` and +`process.execPath`, for example: + +```sh +node -r ts-node/register/transpile-only \ + tests/fixtures/durable-graph-engine-continuation-child.ts +``` + +Use argument arrays, a bounded timeout, a temporary artifact directory, and +JSON files/stdout only. Run exactly two fixture children. Do not share an engine, +registry singleton, module cache, closure, or private in-memory journal between +them. Record and assert distinct producer PID A and continuation PID B. + +The fixture registers a deterministic in-process test provider and uses one +root catalog owner with two keyed items, `A` and `B`, each having an existing +two-node affected closure (`inspect -> summarize`): + +1. Producer process A (`produce`) runs the ordinary engine to full quiescence + with `A@1` and `B@1`. Test code extracts the source checkpoint only through + the existing private context: + + ```ts + const context = (engine as any)._lastContext; + const checkpoint = context.journal.exportGraphCheckpoint(context.sessionId); + ``` + + It writes the JSON-round-tripped checkpoint, event/identity summary, PID, and + call log, then exits. This is fixture-only access, not a Phase 2 public engine + exporter. +2. Fresh continuation process B (`continue`) reads only A's JSON artifacts, + returns changed `A@2` and unchanged `B@1` from the owner, invokes + `continueGraphCheckpoint`, and writes its result, returned checkpoint, + projection, transition history, PID, and provider calls. Before exiting, it + JSON-round-trips the returned checkpoint, restores it with + `ExecutionJournal.restoreGraphCheckpoint` under the same compiled plan, and + proves live/replay projection equality and canonical re-export equality. It + does not enqueue or run another reconciliation. + +The two-process happy-path assertions are exact: + +- B's returned checkpoint begins with a byte/canonical-equal A event prefix and + all suffix events use A's original session; +- all pre-checkpoint instance, claim, node, and generation IDs survive; the + unchanged item's claims/nodes/completed generation slice is deeply equal; +- B's provider calls are exactly owner, `A.inspect`, `A.summarize`; they contain + no `B` generated call and no cold root other than the requested catalog owner; +- the only root-scope suffix `AttemptStarted` is request-discriminated for the + new catalog reconciliation; no original cold-run root gets a suffix start; +- no suffix `AttemptStarted` names any generation completed before that input + checkpoint; changed items retain stable keyed subgraph/node identity while + superseded generations become inactive and replacements get new IDs; +- suffix event IDs are contiguous, request and shared root/catalog ordinals are + the canonical next values, new generated attempts use ordinal 1, and global + fences start at the reconstructed next fence with no gap or regression; +- the first observer event in continuation process B is exactly + `LevelDispatch -> LevelDispatch`; +- the root catalog provider reads the original session and workspace-adjusted + directory only from its existing `executionContext._parentContext`; generated + managed providers read the original session only from the existing + `ManagedRunStartRequest.binding`; and +- B's returned checkpoint is quiescent and survives + `JSON.stringify`/`JSON.parse`, restore, replay, and canonical re-export. The + test stops after this validation. + +Do not add fields to `ExecutionContext`, `ManagedRunStartRequest`, provider +config, or the public continuation result to expose session/workspace evidence. +Generated execution intentionally strips `_parentContext`; make no generated +workspace assertion. The generated fixture provider must use its existing +managed binding for the session assertion. + +The child process is the process-durability proof. Creating two engine objects +inside one Jest process is not an acceptable substitute. + +## Focused failure and ordering tests + +In the same focused test file, cover this matrix before the happy path: + +| Input | Exact rejection | Required negative evidence | +| --- | --- | --- | +| Event/payload changed without re-hashing | `CHECKPOINT_INTEGRITY_MISMATCH` | no provider marker, memory/workspace init, or installed context | +| Valid checkpoint with a semantically different config | `CHECKPOINT_GRAPH_MISMATCH` | same | +| Validly hashed prefix with a pending request/attempt, ready/running generation, or managed lease | `CHECKPOINT_NOT_QUIESCENT` | same | +| Valid checkpoint and same graph, unknown owner | `UNKNOWN_EXPANSION_OWNER` | same and no request suffix | + +Use a child-written provider marker for cross-process negative evidence and +focused Jest spies on `MemoryStore.initialize` and `initializeWorkspace` for +ordering. Run validation failures on a fresh engine and require +`getInstanceProjection()` and `requestCatalogReconciliation()` to fail with +`RUN_NOT_ACTIVE` without test-side field clearing. Separately seed prior +`_lastContext`/`_lastRunner` references, force a setup failure, and require exact +referential preservation. Re-hash fixtures intended to reach graph/quiescence +gates; otherwise the integrity test would give false confidence by masking the +target branch. + +Also assert setup on success: memory initializes once, workspace initialization +runs before the first provider call, and the root catalog provider reads the +resulting `workingDirectory` from its existing +`executionContext._parentContext`. Workspace contents and memory values are +fresh process services; they are not restored from the graph checkpoint. + +## Implementation sequence and gates + +1. Add the builder bootstrap and unit-level ordering assertions. Prove restored + session binding reaches the shared limiter before adding runner logic. +2. Add the engine contract, direct one-request insertion, and fail-fast tests. +3. Add the fixed `LevelDispatch`/wave-1 seed and the isolated initial-observer + correction. +4. Add the two-process deterministic fixture and exact prefix, call-set, + identity, allocator, returned-checkpoint restore, and stop assertions. +5. Build the SDK declarations and confirm the method and types are usable from + `@probelabs/visor/sdk`; do not add a facade if the class export suffices. + +Required focused gates: + +```sh +npx jest tests/engine/durable-graph-engine-continuation.test.ts --runInBand +npx jest tests/unit/snapshot-store.test.ts --runInBand +npm run build:sdk +``` + +Review must reject the change if it introduces a new checkpoint schema, trusts +stored projections/counters, starts at `Init`/wave 0, calls a provider before all +restore/owner gates, relaunches an unchanged completed generation, or proves +“freshness” without a separate PID. No broad suite or recovery machinery is a +Phase 2 prerequisite. + +## Explicit nonclaims + +- No restore of legacy `JournalEntry[]`, `SnapshotJson`, `saveSnapshotToFile`, + `resumeFromSnapshot`, or old runner snapshots. +- No arbitrary runner state, dependency graph, queue, stats, result history, + routing history, observer history, or memory-value restoration. +- No continuation from active root/catalog/generated attempts, pending/running + requests, ready/running generations, or acquired/started/cancel-requested + managed runs. +- No provider session, model stream, workflow child, managed handle, lease, + sandbox/container, timer, cancellation, cleanup, or external-resource resume. +- No graph migration, changed `graphSemanticDigest`, renamed owner, unknown + legacy event, partial prefix, or checkpoint compaction. +- No multiple owners, queued reconciliation batch, daemon/event-loop process + recovery, multi-writer coordination, or crash-atomic checkpoint storage. +- No cryptographic authenticity or protection from a writer able to mutate and + re-hash the checkpoint. +- No claim that normal engine results, frontends, output history, or memory are + durable. Phase 2 proves only exact Graph journal continuation and affected + generated-work reuse across a clean quiescent process boundary. diff --git a/docs/durable-graph-journal-hydration-plan.md b/docs/durable-graph-journal-hydration-plan.md new file mode 100644 index 000000000..68071a2f6 --- /dev/null +++ b/docs/durable-graph-journal-hydration-plan.md @@ -0,0 +1,261 @@ +# Durable Graph Journal Hydration — Phase 1 + +## Outcome + +In one implementation PR, make a completed Graph v2 `ExecutionJournal` portable +through one canonical JSON checkpoint and restorable into a fresh journal with: + +- the exact runtime-event prefix; +- live claim and instance projections equal to pure replay; +- the original session, instance, claim, and generation identities; and +- the next fence, attempt ordinal, and catalog-request ordinal derived only from + the validated prefix. + +Phase 1 is complete when the focused unit suite below passes and the production +diff is limited to `src/snapshot-store.ts`. A type may be exported from that file +for its tests; no SDK, engine, runner, provider, or config surface changes belong +in this phase. + +## Why this boundary + +`ExecutionJournal` already owns the ordered `ClaimRuntimeEvent | +InstanceRuntimeEvent` stream and already proves live/replay equality through +`replayClaimEvents` and `replayInstanceEvents`. Instance identity is bound to the +compiled expansion's `graphSemanticDigest`; payloads and IDs already use +`canonicalJson`/`sha256Canonical`. Reusing those authorities keeps Phase 1 small +and falsifiable. + +Engine snapshot v1 currently stores result-history entries and runner state, not +the Graph v2 runtime prefix or its allocator frontier. Folding engine or process +resume into this change would make a journal-corruption bug indistinguishable +from scheduling, provider, or lease behavior. Those consumers stay outside view +until the journal can independently round-trip and continue. + +## Scope + +Production owner: + +- `src/snapshot-store.ts` + +Test owner: + +- `tests/unit/snapshot-store.test.ts` + +No other file changes are planned. If TypeScript requires a separately exported +checkpoint type, export it from `src/snapshot-store.ts`; do not add an SDK export. + +## Checkpoint v1 + +Add an immutable JSON value with this exact shape: + +```ts +interface GraphJournalCheckpointV1 { + kind: 'visor.graph-journal-checkpoint'; + version: 1; + sessionId: string; + graphSemanticDigest: string; + frontier: { + eventCount: number; + lastEventId: number; + }; + events: readonly (ClaimRuntimeEvent | InstanceRuntimeEvent)[]; + integrity: { + algorithm: 'sha256'; + digest: string; + }; +} +``` + +The integrity digest is `sha256Canonical` of every field except `integrity`. +Export and restore accept no unknown keys, non-JSON values, alternate versions, +or alternate algorithms. `sessionId` is explicit because a future engine layer +must retain it; every event in a non-empty prefix must carry that exact session. + +`frontier.eventCount` must equal `events.length`. Event IDs must be contiguous +from 1, and `frontier.lastEventId` must be 0 for an empty prefix or the final +event ID otherwise. These are observations, not trusted allocator state. + +The checkpoint binds to `claimPlan.expansionPlan.graphSemanticDigest`. Restore +requires an active claim and expansion plan and exact digest equality before it +may install any state. SHA-256 here detects accidental corruption and wrong +artifacts; it is not a signature against an attacker who can rewrite and +re-hash the checkpoint. + +## Journal API and validation order + +Add `journal.exportGraphCheckpoint(sessionId)` and +`ExecutionJournal.restoreGraphCheckpoint(claimPlan, input: unknown)`. Export +returns deeply immutable canonical data; restore returns a new +`ExecutionJournal` only after every gate passes. + +Restore must fail fast, without partially mutating a returned or caller-owned +journal, in this order: + +1. Validate exact envelope shape, JSON/canonical representability, kind, version, + algorithm, session, and frontier scalar ranges. +2. Recompute and compare the integrity digest. +3. Compare the checkpoint and current-plan `graphSemanticDigest`. +4. Validate contiguous event IDs, the exact single-session prefix, each event's + exact discriminated shape, and its authority under the current plan. +5. Replay root claim events with `replayClaimEvents(events, claimPlan)` using the + exact routing below. +6. Replay instance events with `replayInstanceEvents(events)` using the exact + routing below and its atomic managed-run batch grammar. +7. Require a quiescent frontier: no started root attempt, pending/running catalog + request, ready/running node generation, or acquired/started/cancel-requested + managed run. Phase 1 checkpoints completed expansion state only. +8. Reconstruct allocator maps and the next fence from the ordered events, then + atomically install the immutable prefix, both replayed projections, and the + derived allocators in the fresh journal. + +Use a dedicated checkpoint error with these stable codes: +`INVALID_CHECKPOINT_ENVELOPE`, `CHECKPOINT_INTEGRITY_MISMATCH`, +`CHECKPOINT_GRAPH_MISMATCH`, `INVALID_CHECKPOINT_PREFIX`, +`CHECKPOINT_SESSION_MISMATCH`, `CHECKPOINT_PLAN_AUTHORITY_MISMATCH`, and +`CHECKPOINT_NOT_QUIESCENT`. Preserve specific claim/instance kernel failures as +causes; never turn invalid replay into an empty journal or partial success. + +## Exact event routing and plan authority + +Validate the mixed prefix before either replay. Every event must have the exact +required/optional keys of one existing runtime-event interface; reject extra +keys, missing keys, wrong root/keyed scope, and hybrid discriminators. + +- `AttemptStarted`, `CheckScheduled`, `AttemptCompleted`, and `AttemptFailed` + with `nodeGenerationId` and `nodeInstanceId` (and no `requestId`) are generated + lifecycle events and route only to instance replay. +- Those four lifecycle types with `requestId` (and no node discriminator) are + root-scope catalog-request events and route to both claim and instance replay; + `AttemptCompleted` also requires `catalogClaimId`. +- Root lifecycle events with neither discriminator route only to claim replay. + Root `ClaimPublished` has neither discriminator and routes only to claim replay, + including a catalog claim published by a catalog-request attempt. +- `ClaimPublished` with both node discriminators routes only to instance replay. + `ClaimPublished` with `requestId`, or any lifecycle event carrying both request + and node discriminators, is invalid. +- `CatalogReconciliationRequested`, `SubgraphExpanded`, + `ControllerItemClaimPublished`, `NodeGenerationInactivated`, + `NodeGenerationActivated`, `SubgraphTombstoned`, and every `ManagedRun*` event + route only to instance replay. No other type or shape is admitted. + +Graph-digest equality is necessary but not sufficient. While scanning in event +order, resolve each instance event against the compiled plan and the projection +prefix before it: + +- every reconciliation owner must be an exact `expansionPlan.byOwner` entry; +- every root or nested `SubgraphExpanded` must resolve to that exact compiled + expansion and match its `graphSemanticDigest`, `expansionSpecDigest`, + `templateDigest`, root/nested owner fields, catalog claim reference, complete + template-node key set, and derived node-instance IDs; +- every controller item publication must match the owning expansion's owner, + expansion digest, `itemClaimRef`, and item validator; +- every generation activation must name the owning compiled template node and + match its check ID, execution-config digest, and current nested catalog-claim + reference (present exactly when that node owns a nested expansion); and +- inactivation, tombstone, generated lifecycle, and managed lifecycle events + must resolve to the exact already-projected instance, generation, attempt, and + binding authority checked by the existing instance reducer. + +Root claim emission/consumption and schema authority remains with +`replayClaimEvents`. Do not accept event-carried digests or IDs as substitutes +for current-plan lookup. + +## Deterministic allocator reconstruction + +Stored counters are forbidden. Derive all authority from validated events: + +- `nextFence`: scan every root, catalog, and generated `AttemptStarted` in their + single interleaved event order. Fences must be the journal-produced global + sequence `1..N`; set the restored frontier to `N`. +- Root and catalog attempts share one ordinal allocator. Group both kinds by + `canonicalJson({ sessionId, checkId, scope })`, count them together in event + order, and verify each `attemptId` equals + `sha256Canonical({ sessionId, checkId, scope, ordinal })`. +- A valid node generation can start only once. Each generated `AttemptStarted` + must therefore be the sole start for its `nodeGenerationId`/scope and have + `attemptId === sha256Canonical({ nodeGenerationId, ordinal: 1 })`; reject a + second start instead of reconstructing a generated ordinal greater than one. +- Catalog request ordinals: group `CatalogReconciliationRequested` events by + `expansionOwnerCheck`, require the event ordinal to be the next count, and + retain the existing derived request-ID validation. + +Reject gaps, regressions, duplicates, unsafe integers, or an attempt/request ID +that does not match its reconstructed ordinal. After restore, existing start and +request methods must allocate exactly the next ordinal and a fence of `N + 1`. + +## Focused tests + +Add table-driven tests beside the existing journal replay tests. Each assertion +must operate through the new checkpoint/restore surface rather than assigning +private fields. + +1. **Exact JSON round trip.** Build a completed keyed expansion with generated + claims, checkpoint it, pass it through `JSON.stringify`/`JSON.parse`, restore, + and require exact event equality, identical claim/instance projections, + live/replay equality, recursive immutability, and byte-identical canonical + re-export including the integrity digest. +2. **Integrity, routing, and graph binding.** Independently alter an event + payload, digest, `graphSemanticDigest`, kind/version, add an unknown key, add + request+node hybrid discriminators, and alter expansion/activation authority. + A raw post-checkpoint mutation must first prove integrity rejection. Every + case intended to reach a later shape, routing, frontier, or plan-authority gate + must recompute the envelope integrity digest after mutation. Also restore an + unmodified checkpoint under a semantically different compiled graph. Each + case must synchronously reject with its exact code before a journal is returned. +3. **Frontier grammar.** Cover empty and completed prefixes, mismatched + event-count/last-event metadata, non-contiguous event IDs, mixed sessions, + a prefix cut inside an atomic managed terminal batch, and otherwise valid + prefixes ending with started attempts, pending/running requests, + ready/running generations, or managed runs in each of `acquired`, `started`, + and `cancel_requested`. Re-hash every deliberately edited envelope so the + intended frontier gate is exercised. Only empty or quiescent completed + prefixes restore. +4. **Stale fence remains stale.** Restore a completed prefix, begin the next + authorized attempt, then submit a schedule/terminal operation carrying a + pre-checkpoint attempt ID or fence. Require `STALE_FENCE` and no appended + runtime event. +5. **Exact next fence and ordinals.** Restore a prefix containing an initial root + catalog attempt, generated attempts, and at least one reconciliation attempt + for that same root owner. Require root and catalog attempts to have the shared + authority ordinals `1, 2, ...`, while all interleaved starts consume one global + fence sequence. Require the next request ordinal/ID and next root/catalog + attempt ordinal/ID to match canonical derivation. After a changed catalog + activates a new generation, require its only valid start to use generated + ordinal 1 and the next global fence; a second start for that generation must + fail. Re-export/restore again and repeat once to exclude hidden process-local + allocator state. + +The implementation gate is: + +```sh +npx jest tests/unit/snapshot-store.test.ts --runInBand +``` + +Also require the focused file to type-check under the repository's normal test +compilation. No broad suite is required until a later engine integration phase. + +## Fail-fast review gates + +- No counter, projection, or integrity digest supplied by the checkpoint is + trusted without reconstruction or replay. +- No provider, model, network, filesystem, timer, or engine callback is invoked. +- Restore is all-or-nothing and does not mutate the input checkpoint. +- Existing live/replay reducers remain the sole projection authority; do not add + a second reducer or deserialize projections directly. +- Existing canonical hashing and `graphSemanticDigest` remain the sole identity + and graph-binding authorities. +- The production/test file allowlist is enforced in review. + +## Nonclaims deferred beyond Phase 1 + +- No `StateMachineExecutionEngine`, `StateMachineRunner`, wave, or process resume. +- No integration with `saveSnapshotToFile`, `loadSnapshotFromFile`, Slack, or SDK. +- No mid-attempt/provider resume and no managed handle, sandbox, lease, cancel, + cleanup, or external-resource resurrection. +- No multi-writer journal, locking, replication, storage adapter, compaction, or + partial-prefix streaming. +- No graph migration or compatibility across a changed `graphSemanticDigest`. +- No cryptographic authenticity, confidentiality, secret storage, or key + management. +- No claim that a restored journal alone makes a completed engine run live; that + is the explicit subject of a later integration phase. diff --git a/src/config.ts b/src/config.ts index 7aee39ea2..df2cd7b3e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ import { ConfigMerger } from './utils/config-merger'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { validateJsSyntax } from './utils/sandbox'; +import { compileClaimPlan } from './state-machine/graph/claim-plan'; /** * Valid event triggers for checks @@ -64,6 +65,7 @@ export class ConfigManager { 'git-checkout', 'a2a', 'utcp', + 'proof-admit', ]; private validEventTriggers: EventTrigger[] = [...VALID_EVENT_TRIGGERS]; private validOutputFormats: ConfigOutputFormat[] = ['table', 'json', 'markdown', 'sarif']; @@ -826,6 +828,22 @@ export class ConfigManager { } } + // Graph v2 claim and expansion cross-reference semantics cannot be expressed by generated + // JSON Schema. Compile at the real ConfigManager boundary so malformed + // refs, duplicate emitters, unsupported scope, and cycles fail prelaunch. + try { + compileClaimPlan(config); + } catch (error) { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code: unknown }).code) + : undefined; + errors.push({ + field: 'claim_types/subgraphs', + message: `${code ? `${code}: ` : ''}${error instanceof Error ? error.message : String(error)}`, + }); + } + // Validate sandbox configuration if (config.sandboxes) { const sandboxNames = Object.keys(config.sandboxes); diff --git a/src/generated/config-schema.ts b/src/generated/config-schema.ts index d6356238d..eff000b66 100644 --- a/src/generated/config-schema.ts +++ b/src/generated/config-schema.ts @@ -79,6 +79,15 @@ export const configSchema = { description: "Check configurations (legacy, use 'steps' instead) - always populated after normalization", }, + claim_types: { + $ref: '#/definitions/Record%3Cstring%2CClaimTypeConfig%3E', + description: + 'Exact versioned candidate-claim schemas. Presence activates Graph v2 C1 semantics.', + }, + subgraphs: { + $ref: '#/definitions/Record%3Cstring%2CSubgraphConfig%3E', + description: 'Named one-level templates used by C2 keyed expansion declarations.', + }, output: { $ref: '#/definitions/OutputConfig', description: 'Output configuration (optional - defaults provided)', @@ -868,6 +877,26 @@ export const configSchema = { description: 'Check IDs that this check depends on (optional). Accepts single string or array.', }, + emits: { + type: 'array', + items: { + $ref: '#/definitions/ClaimEmissionConfig', + }, + description: "Candidate claims emitted from this check's raw terminal output.", + minItems: 1, + }, + consumes: { + type: 'array', + items: { + $ref: '#/definitions/ClaimConsumptionConfig', + }, + description: 'Exact candidate claims required before this check may run.', + minItems: 1, + }, + expand: { + $ref: '#/definitions/ExpansionConfig', + description: 'Optional C2 keyed expansion owned by this root check.', + }, group: { type: 'string', description: @@ -1157,7 +1186,7 @@ export const configSchema = { description: 'Arguments/inputs for the workflow', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-17440-33481-src_types_config.ts-0-65638%3E%3E', description: 'Override specific step configurations in the workflow', }, output_mapping: { @@ -1174,7 +1203,7 @@ export const configSchema = { 'Config file path - alternative to workflow ID (loads a Visor config file as workflow)', }, workflow_overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-17440-33481-src_types_config.ts-0-65638%3E%3E', description: 'Alias for overrides - workflow step overrides (backward compatibility)', }, ref: { @@ -1279,6 +1308,7 @@ export const configSchema = { 'git-checkout', 'a2a', 'utcp', + 'proof-admit', ], description: 'Valid check types in configuration', }, @@ -1738,6 +1768,99 @@ export const configSchema = { }, description: 'Environment variable reference configuration', }, + ClaimEmissionConfig: { + type: 'object', + properties: { + claim: { + type: 'string', + }, + from: { + type: 'string', + const: 'output', + }, + }, + required: ['claim', 'from'], + additionalProperties: false, + description: 'Publish the raw terminal provider output as an exact claim type/version.', + patternProperties: { + '^x-': {}, + }, + }, + ClaimConsumptionConfig: { + type: 'object', + properties: { + claim: { + type: 'string', + }, + cardinality: { + type: 'string', + const: 'one', + description: + 'C1 root declarations require this explicitly; C2 templates default it to one.', + }, + as: { + type: 'string', + description: 'Immutable provider-context binding used by generated template checks.', + }, + }, + required: ['claim'], + additionalProperties: false, + description: 'Require one active candidate claim of an exact type/version.', + patternProperties: { + '^x-': {}, + }, + }, + ExpansionConfig: { + type: 'object', + properties: { + claim: { + type: 'string', + description: 'Catalog claim emitted by this same root check.', + }, + template: { + type: 'string', + description: 'Named subgraph template to instantiate for every keyed catalog item.', + }, + items_pointer: { + type: 'string', + description: 'RFC 6901 pointer from the catalog payload to its item array.', + }, + key_pointer: { + type: 'string', + description: 'RFC 6901 pointer, relative to one item, to its stable key.', + }, + item_claim: { + type: 'string', + description: 'Claim published by the controller for one schema-valid item.', + }, + coverage: { + type: 'object', + properties: { + outcome_claim: { + type: 'string', + description: "Claim emitted exactly once by the template's terminal sink operation.", + }, + class_pointer: { + type: 'string', + description: 'RFC 6901 pointer from the outcome payload to its terminal class.', + }, + }, + required: ['outcome_claim', 'class_pointer'], + additionalProperties: false, + description: + 'Optional deterministic terminal coverage projection for the selected catalog.', + patternProperties: { + '^x-': {}, + }, + }, + }, + required: ['claim', 'template', 'items_pointer', 'key_pointer', 'item_claim'], + additionalProperties: false, + description: 'Compile one terminal catalog claim into stable keyed template instances.', + patternProperties: { + '^x-': {}, + }, + }, CustomTemplateConfig: { type: 'object', properties: { @@ -1918,7 +2041,7 @@ export const configSchema = { description: 'Custom output name (defaults to workflow name)', }, overrides: { - $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E%3E', + $ref: '#/definitions/Record%3Cstring%2CPartial%3Cinterface-src_types_config.ts-17440-33481-src_types_config.ts-0-65638%3E%3E', description: 'Step overrides', }, output_mapping: { @@ -1933,14 +2056,14 @@ export const configSchema = { '^x-': {}, }, }, - 'Record>': + 'Record>': { type: 'object', additionalProperties: { - $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-15521-31208-src_types_config.ts-0-63102%3E', + $ref: '#/definitions/Partial%3Cinterface-src_types_config.ts-17440-33481-src_types_config.ts-0-65638%3E', }, }, - 'Partial': { + 'Partial': { type: 'object', additionalProperties: false, }, @@ -2181,6 +2304,67 @@ export const configSchema = { '^x-': {}, }, }, + 'Record': { + type: 'object', + additionalProperties: { + $ref: '#/definitions/ClaimTypeConfig', + }, + }, + ClaimTypeConfig: { + type: 'object', + properties: { + schema: { + $ref: '#/definitions/Record%3Cstring%2Cunknown%3E', + description: 'JSON Schema used for strict candidate publication validation.', + }, + }, + required: ['schema'], + additionalProperties: false, + description: 'A schema-bound candidate claim declaration.', + patternProperties: { + '^x-': {}, + }, + }, + 'Record': { + type: 'object', + additionalProperties: { + $ref: '#/definitions/SubgraphConfig', + }, + }, + SubgraphConfig: { + type: 'object', + properties: { + input: { + $ref: '#/definitions/SubgraphInputConfig', + }, + checks: { + $ref: '#/definitions/Record%3Cstring%2CCheckConfig%3E', + }, + }, + required: ['input', 'checks'], + additionalProperties: false, + description: 'A statically compiled, one-level generated subgraph template.', + patternProperties: { + '^x-': {}, + }, + }, + SubgraphInputConfig: { + type: 'object', + properties: { + name: { + type: 'string', + }, + claim: { + type: 'string', + }, + }, + required: ['name', 'claim'], + additionalProperties: false, + description: 'One externally supplied claim binding for a generated subgraph template.', + patternProperties: { + '^x-': {}, + }, + }, OutputConfig: { type: 'object', properties: { diff --git a/src/providers/check-provider-registry.ts b/src/providers/check-provider-registry.ts index 64ba742b7..c5611a002 100644 --- a/src/providers/check-provider-registry.ts +++ b/src/providers/check-provider-registry.ts @@ -17,6 +17,10 @@ import { GitCheckoutProvider } from './git-checkout-provider'; import { A2ACheckProvider } from './a2a-check-provider'; import { UtcpCheckProvider } from './utcp-check-provider'; import { CustomToolDefinition } from '../types/config'; +import { + ProofAdmitCheckProvider, + PROOF_ADMIT_PROVIDER_NAME, +} from './proof-admit-check-provider'; /** * Registry for managing check providers @@ -60,6 +64,9 @@ export class CheckProviderRegistry { this.register(new WorkflowCheckProvider()); this.register(new GitCheckoutProvider()); this.register(new A2ACheckProvider()); + // Reserved EXP-0205 provider. It is installed by the registry itself and + // cannot be replaced through the public register/unregister API. + this.registerBuiltIn(new ProofAdmitCheckProvider()); // Try to register UtcpCheckProvider - it may fail if dependencies are missing try { @@ -100,11 +107,22 @@ export class CheckProviderRegistry { } } + private registerBuiltIn(provider: CheckProvider): void { + const name = provider.getName(); + if (this.providers.has(name)) { + throw new Error(`Provider '${name}' is already registered`); + } + this.providers.set(name, provider); + } + /** * Register a check provider */ register(provider: CheckProvider): void { const name = provider.getName(); + if (name === PROOF_ADMIT_PROVIDER_NAME) { + throw new Error(`Provider '${name}' is reserved and cannot be registered publicly`); + } if (this.providers.has(name)) { throw new Error(`Provider '${name}' is already registered`); } @@ -119,6 +137,9 @@ export class CheckProviderRegistry { * Unregister a check provider */ unregister(name: string): void { + if (name === PROOF_ADMIT_PROVIDER_NAME) { + throw new Error(`Provider '${name}' is reserved and cannot be unregistered`); + } if (!this.providers.has(name)) { throw new Error(`Provider '${name}' not found`); } diff --git a/src/providers/check-provider.interface.ts b/src/providers/check-provider.interface.ts index cd6424ffa..d1c2877cf 100644 --- a/src/providers/check-provider.interface.ts +++ b/src/providers/check-provider.interface.ts @@ -1,6 +1,40 @@ import { PRInfo } from '../pr-analyzer'; import { ReviewSummary } from '../reviewer'; import { EnvConfig, HumanInputRequest } from '../types/config'; +import type { ScopePath } from '../snapshot-store'; +import type { + KeyedScopePath, + ManagedRunBindingV1, +} from '../state-machine/graph/instance-kernel'; + +interface CandidateClaimInputBase { + readonly claimId: string; + readonly claim: string; + readonly payload: unknown; + readonly payloadFingerprint: string; + readonly producerCheckId: string; + readonly scope: Readonly | KeyedScopePath; + readonly parentClaimIds: readonly string[]; +} + +/** Exact, immutable candidate claim view granted to a consuming provider. */ +export type CandidateClaimInput = CandidateClaimInputBase & + ( + | { + /** Root and generated claims retain their actual producer attempt authority. */ + readonly provenance?: 'attempt'; + readonly attemptId: string; + readonly fence: number; + } + | { + /** Controller item claims are derived from an expansion and have no fake attempt. */ + readonly provenance: 'controller'; + readonly catalogClaimId: string; + readonly incarnation: number; + readonly attemptId?: never; + readonly fence?: never; + } + ); /** * Configuration for a check provider @@ -60,6 +94,14 @@ export interface ExecutionContext { workflowInputs?: Record; /** Custom arguments passed from on_init 'with' directive */ args?: Record; + /** Exact declared candidate claims. No global/nearest output fallback is applied. */ + claims?: Readonly>; + /** Journal-derived dynamic node identity; present only for generated C2 work. */ + nodeInstanceId?: string; + /** Journal-derived active generation identity; present only for generated C2 work. */ + nodeGenerationId?: string; + /** Exact immutable keyed scope for generated C2 work. */ + scope?: Readonly | KeyedScopePath; /** SDK hooks for human input and check completion */ hooks?: { onHumanInput?: (request: HumanInputRequest) => Promise; @@ -105,6 +147,66 @@ export interface ExecutionContext { responseCapture?: (text: string) => void; } +/** Immutable controller inputs for synchronous managed-run acquisition. */ +export interface ManagedRunStartRequest { + readonly prInfo: PRInfo; + readonly checkConfig: CheckProviderConfig; + readonly dependencyResults: ReadonlyMap; + readonly executionContext: ExecutionContext; + readonly binding: ManagedRunBindingV1; +} + +export interface ManagedRunStartedReceiptV1 { + readonly version: 1; + readonly kind: 'started'; + readonly binding: ManagedRunBindingV1; +} + +export interface ManagedRunSucceededOutcomeV1 { + readonly version: 1; + readonly kind: 'succeeded'; + readonly binding: ManagedRunBindingV1; + readonly summary: ReviewSummary; +} + +export interface ManagedRunFailedOutcomeV1 { + readonly version: 1; + readonly kind: 'failed'; + readonly binding: ManagedRunBindingV1; +} + +export type ManagedRunOutcomeV1 = + | ManagedRunSucceededOutcomeV1 + | ManagedRunFailedOutcomeV1; + +export interface ManagedRunCancelReceiptV1 { + readonly version: 1; + readonly kind: 'cancelled'; + readonly binding: ManagedRunBindingV1; + readonly reason: 'deadline'; +} + +export interface ManagedRunCleanupReceiptV1 { + readonly version: 1; + readonly kind: 'cleanup'; + readonly binding: ManagedRunBindingV1; + readonly status: 'clean'; + readonly activeChildren: 0; + readonly activeResources: 0; +} + +/** Exact close-capable handle whose authority is snapshotted synchronously by Visor. */ +export interface ManagedAgentRun { + readonly binding: ManagedRunBindingV1; + readonly started: Promise; + readonly outcome: Promise; + readonly cancel: ( + reason: 'deadline', + fence: number + ) => Promise; + readonly close: () => Promise; +} + /** * Abstract base class for all check providers * Implementing classes provide specific check functionality (AI, tool, script, etc.) @@ -142,6 +244,12 @@ export abstract class CheckProvider { context?: ExecutionContext ): Promise; + /** + * Synchronously acquire an exact close-capable managed run. Visor validates + * and snapshots the returned handle before awaiting provider-controlled data. + */ + startManaged?(request: ManagedRunStartRequest): ManagedAgentRun; + /** * Get the list of configuration keys this provider supports * Used for documentation and validation diff --git a/src/providers/proof-admit-check-provider.ts b/src/providers/proof-admit-check-provider.ts new file mode 100644 index 000000000..b199cb523 --- /dev/null +++ b/src/providers/proof-admit-check-provider.ts @@ -0,0 +1,85 @@ +import { PRInfo } from '../pr-analyzer'; +import { CheckProvider, CheckProviderConfig, ExecutionContext, CandidateClaimInput } from './check-provider.interface'; +import { ReviewSummary } from '../reviewer'; +import { immutableCanonicalValue, sha256Canonical } from '../state-machine/graph/claim-kernel'; +import { + PROOF_ADMIT_PROVIDER_TYPE, + PROOF_CANDIDATE_CLAIM, +} from '../state-machine/graph/instance-plan'; + +export const PROOF_ADMIT_PROVIDER_NAME = PROOF_ADMIT_PROVIDER_TYPE; +type AdmissionCandidate = Readonly<{ claimId: string; claim: typeof PROOF_CANDIDATE_CLAIM; payload: unknown; payloadFingerprint: string; producerCheckId: string; attemptId: string; fence: number; scope: unknown; parentClaimIds: readonly string[] }>; +type AdmissionRequest = Readonly<{ version: 1; candidate: AdmissionCandidate }>; +type AdmissionReceipt = Readonly<{ version: 1; kind: 'admitted'; candidateClaimId: string; candidateClaim: typeof PROOF_CANDIDATE_CLAIM; candidateFingerprint: string; candidateAttemptId: string; candidateFence: number; scope: unknown; parentClaimIds: readonly string[] }>; +type AdmissionDecision = + | Readonly<{ kind: 'accepted'; receipt: AdmissionReceipt }> + | Readonly<{ kind: 'rejected'; reason: string }>; +type AdmissionSink = Readonly<{ decide(request: AdmissionRequest): AdmissionDecision }>; + +function fail(code: string, detail: string): never { throw new Error(`${code}: ${detail}`); } +function deeplyFrozen(value: unknown, seen = new Set()): boolean { + if (!value || typeof value !== 'object') return true; + if (seen.has(value)) return true; + seen.add(value); + return Object.isFrozen(value) && Object.values(value as Record).every(child => deeplyFrozen(child, seen)); +} + +function detachCandidate(value: unknown): AdmissionCandidate { + const candidate = value as CandidateClaimInput; + if (!value || typeof value !== 'object' || Array.isArray(value) || candidate.provenance !== 'attempt' || candidate.claim !== PROOF_CANDIDATE_CLAIM || typeof candidate.claimId !== 'string' || typeof candidate.payloadFingerprint !== 'string' || typeof candidate.producerCheckId !== 'string' || typeof candidate.attemptId !== 'string' || !Number.isSafeInteger(candidate.fence) || !Array.isArray(candidate.scope) || !Array.isArray(candidate.parentClaimIds)) fail('PROOF_ADMISSION_INVALID_CANDIDATE', 'candidate lacks exact attempt provenance'); + const detached = immutableCanonicalValue({ + claimId: candidate.claimId, claim: PROOF_CANDIDATE_CLAIM as typeof PROOF_CANDIDATE_CLAIM, payload: candidate.payload, + payloadFingerprint: candidate.payloadFingerprint, producerCheckId: candidate.producerCheckId, + attemptId: candidate.attemptId, fence: candidate.fence, scope: candidate.scope, + parentClaimIds: candidate.parentClaimIds, + }); + if (sha256Canonical(detached.payload) !== detached.payloadFingerprint) fail('PROOF_ADMISSION_INVALID_CANDIDATE', 'candidate fingerprint does not match payload'); + return detached; +} + +function detachReceipt(value: unknown, candidate: AdmissionCandidate): AdmissionReceipt { + if (!value || typeof value !== 'object' || Array.isArray(value)) fail('PROOF_ADMISSION_INVALID_RECEIPT', 'sink returned a non-object receipt'); + const receipt = value as Record; + if (!deeplyFrozen(value) || receipt.version !== 1 || receipt.kind !== 'admitted' || receipt.candidateClaimId !== candidate.claimId || receipt.candidateClaim !== PROOF_CANDIDATE_CLAIM || receipt.candidateFingerprint !== candidate.payloadFingerprint || receipt.candidateAttemptId !== candidate.attemptId || receipt.candidateFence !== candidate.fence || sha256Canonical(receipt.scope) !== sha256Canonical(candidate.scope) || !Array.isArray(receipt.parentClaimIds) || sha256Canonical(receipt.parentClaimIds) !== sha256Canonical(candidate.parentClaimIds)) fail('PROOF_ADMISSION_INVALID_RECEIPT', 'sink receipt is mutable, has wrong parents, or is not bound to candidate'); + return immutableCanonicalValue({ version: 1, kind: 'admitted' as const, candidateClaimId: candidate.claimId, candidateClaim: PROOF_CANDIDATE_CLAIM as typeof PROOF_CANDIDATE_CLAIM, candidateFingerprint: candidate.payloadFingerprint, candidateAttemptId: candidate.attemptId, candidateFence: candidate.fence, scope: receipt.scope, parentClaimIds: receipt.parentClaimIds }); +} + +const proofAdmissionSink = Object.freeze({ + decide(request: AdmissionRequest): AdmissionDecision { + const scope = request.candidate.scope as readonly unknown[]; + const first = scope[0] as Record | undefined; + if (first?.key !== 'A') return { kind: 'rejected', reason: 'deterministic fixture rejection' }; + return { kind: 'accepted', receipt: immutableCanonicalValue({ version: 1, kind: 'admitted' as const, candidateClaimId: request.candidate.claimId, candidateClaim: PROOF_CANDIDATE_CLAIM as typeof PROOF_CANDIDATE_CLAIM, candidateFingerprint: request.candidate.payloadFingerprint, candidateAttemptId: request.candidate.attemptId, candidateFence: request.candidate.fence, scope: request.candidate.scope, parentClaimIds: request.candidate.parentClaimIds }) }; + }, +}); + +const INTERNAL_PROOF_ADMISSION_BOOTSTRAP = Symbol('proof-admission-internal-bootstrap'); +export function createProofAdmitProviderForFocusedTest(sink: AdmissionSink): ProofAdmitCheckProvider { return new ProofAdmitCheckProvider(sink, INTERNAL_PROOF_ADMISSION_BOOTSTRAP); } + +export class ProofAdmitCheckProvider extends CheckProvider { + private readonly sink: AdmissionSink; + constructor(sink: AdmissionSink = proofAdmissionSink, token?: typeof INTERNAL_PROOF_ADMISSION_BOOTSTRAP) { + super(); + if (sink !== proofAdmissionSink && token !== INTERNAL_PROOF_ADMISSION_BOOTSTRAP) fail('PROOF_ADMISSION_INVALID_BOOTSTRAP', 'custom sink requires internal bootstrap'); + this.sink = sink; + } + getName(): string { return PROOF_ADMIT_PROVIDER_NAME; } + getDescription(): string { return 'Sealed built-in proof candidate admission provider'; } + async validateConfig(config: unknown): Promise { + return !!config && typeof config === 'object' && (config as CheckProviderConfig).type === PROOF_ADMIT_PROVIDER_NAME; + } + async execute(_pr: PRInfo, config: CheckProviderConfig, _deps?: Map, context?: ExecutionContext): Promise { + if (config.type !== PROOF_ADMIT_PROVIDER_NAME) fail('PROOF_ADMISSION_INVALID_CONFIG', `expected type ${PROOF_ADMIT_PROVIDER_NAME}`); + const claims = Object.values(context?.claims || {}); + if (claims.length !== 1 || claims[0].claim !== PROOF_CANDIDATE_CLAIM) { + fail('PROOF_ADMISSION_INVALID_CANDIDATE', 'exactly one proof candidate claim is required'); + } + const candidate = detachCandidate(claims[0]); + const decision = this.sink.decide(immutableCanonicalValue({ version: 1, candidate })); + if (decision.kind === 'rejected') throw new Error('PROOF_ADMISSION_REJECTED'); + return { issues: [], output: detachReceipt(decision.receipt, candidate) }; + } + getSupportedConfigKeys(): string[] { return ['type']; } + async isAvailable(): Promise { return true; } + getRequirements(): string[] { return ['No external dependencies required']; } +} diff --git a/src/sdk.ts b/src/sdk.ts index 06c63da42..cafb1f3a0 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -9,8 +9,24 @@ import { ConfigManager } from './config'; import type { AnalysisResult } from './output-formatters'; import type { VisorConfig, TagFilter, HumanInputRequest } from './types/config'; import type { ExecutionContext } from './providers/check-provider.interface'; +import type { + GraphCheckpointContinuationInput, + GraphCheckpointContinuationResult, +} from './state-machine-execution-engine'; +import type { GraphJournalCheckpointV1 } from './snapshot-store'; +export { StateMachineExecutionEngine }; export type { VisorConfig, TagFilter, HumanInputRequest, ExecutionContext }; +export type { + GraphCheckpointContinuationInput, + GraphCheckpointContinuationResult, + GraphJournalCheckpointV1, +}; +export type { + ExpansionCoverageProjection, + InstanceClaimProjection, + InstanceProjection, +} from './state-machine/graph/instance-kernel'; export interface VisorOptions { cwd?: string; diff --git a/src/snapshot-store.ts b/src/snapshot-store.ts index 3a93e76ce..4f7461944 100644 --- a/src/snapshot-store.ts +++ b/src/snapshot-store.ts @@ -5,9 +5,529 @@ import type { ReviewSummary } from './reviewer'; import type { EventTrigger } from './types/config'; +import type { CandidateClaimInput } from './providers/check-provider.interface'; +import { + buildClaimPublishedEvent, + canonicalJson, + createInitialClaimProjection, + exactActiveClaimIds, + immutableCanonicalValue, + immutableRuntimeEvent, + ClaimKernelError, + reduceClaimEvent, + replayClaimEvents, + sha256Canonical, + type AttemptCompletedEvent, + type AttemptFailedEvent, + type AttemptStartedEvent, + type CheckScheduledEvent, + type ClaimProjection, + type ClaimRuntimeEvent, +} from './state-machine/graph/claim-kernel'; +import type { ClaimPlan } from './state-machine/graph/claim-plan'; +import { + createInitialInstanceProjection, + deriveCatalogRequestId, + canonicalCatalogKey, + deriveControllerItemClaimId, + deriveItemFingerprint, + deriveManagedRunId, + deriveNodeGenerationId, + deriveNodeInstanceId, + deriveSubgraphInstanceId, + immutableInstanceEvent, + queryReadyGenerations, + reduceInstanceEvent, + reduceInstanceEventBatch, + replayInstanceEvents, + projectExpansionCoverage, + type CatalogReconciliationRequestedEvent, + type CatalogRequestAttemptStartedEvent, + type CatalogRequestCheckScheduledEvent, + type InstanceProjection, + type InstanceRuntimeEvent, + type ExpansionCoverageProjection, + type NodeGenerationProjection, + type KeyedScopePath, + type RootScopePath, + type GeneratedAttemptStartedEvent, + type GeneratedCheckScheduledEvent, + type GeneratedClaimPublishedEvent, + type ManagedRunAcquisitionFailureCode, + type ManagedRunBindingV1, + type ManagedRunCleanupStatus, + type ManagedRunFailureCode, +} from './state-machine/graph/instance-kernel'; +import { + qualifiedNestedExpansionOwner, + resolveJsonPointer, + type CompiledExpansion, +} from './state-machine/graph/instance-plan'; export type ScopePath = Array<{ check: string; index: number }>; +type CatalogAttemptStartedEvent = AttemptStartedEvent & CatalogRequestAttemptStartedEvent; +type CatalogCheckScheduledEvent = CheckScheduledEvent & CatalogRequestCheckScheduledEvent; +type CatalogScheduleAuthority = Pick< + CatalogRequestAttemptStartedEvent, + 'requestId' | 'attemptId' | 'fence' +>; +type GeneratedScheduleAuthority = Pick< + GeneratedAttemptStartedEvent, + 'nodeGenerationId' | 'attemptId' | 'fence' +>; +type WithoutEventId = T extends { readonly eventId: number } ? Omit : never; +type StagedInstanceRuntimeEvent = WithoutEventId; + +/** The portable, canonical Graph-v2 runtime prefix. */ +export interface GraphJournalCheckpointV1 { + readonly kind: 'visor.graph-journal-checkpoint'; + readonly version: 1; + readonly sessionId: string; + readonly graphSemanticDigest: string; + readonly frontier: { + readonly eventCount: number; + readonly lastEventId: number; + }; + readonly events: readonly (ClaimRuntimeEvent | InstanceRuntimeEvent)[]; + readonly integrity: { + readonly algorithm: 'sha256'; + readonly digest: string; + }; +} + +export class GraphJournalCheckpointError extends Error { + readonly code: + | 'INVALID_CHECKPOINT_ENVELOPE' + | 'CHECKPOINT_INTEGRITY_MISMATCH' + | 'CHECKPOINT_GRAPH_MISMATCH' + | 'INVALID_CHECKPOINT_PREFIX' + | 'CHECKPOINT_SESSION_MISMATCH' + | 'CHECKPOINT_PLAN_AUTHORITY_MISMATCH' + | 'CHECKPOINT_NOT_QUIESCENT'; + + constructor( + code: GraphJournalCheckpointError['code'], + message: string, + options?: { cause?: unknown } + ) { + super(message, options); + this.name = 'GraphJournalCheckpointError'; + this.code = code; + } +} + +const CHECKPOINT_SHA256 = /^[0-9a-f]{64}$/; + +function checkpointObject(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined; +} + +function checkpointHasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function checkpointString(value: unknown, field: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', `${field} must be a non-empty string`); + } +} + +function checkpointSafeInteger(value: unknown, field: string, minimum = 0): asserts value is number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', `${field} must be a safe integer >= ${minimum}`); + } +} + +function checkpointExactEventKeys(event: Record, expected: readonly string[]): void { + if (!checkpointHasExactKeys(event, expected)) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', `Runtime event ${String(event.type)} has unknown or missing fields`); + } +} + +function isRecord(value: unknown): value is Record { + return !!checkpointObject(value); +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function checkpointWrap( + code: GraphJournalCheckpointError['code'], + message: string, + error: unknown +): GraphJournalCheckpointError { + if (error instanceof GraphJournalCheckpointError) return error; + return new GraphJournalCheckpointError(code, message, { cause: error }); +} + +type CheckpointRuntimeEvent = ClaimRuntimeEvent | InstanceRuntimeEvent; + +const ATTEMPT_BASE_KEYS = ['version', 'type', 'eventId', 'sessionId', 'checkId', 'scope', 'attemptId', 'fence'] as const; + +function eventHasNodeDiscriminator(event: Record): boolean { + return hasOwn(event, 'nodeInstanceId') || hasOwn(event, 'nodeGenerationId'); +} + +function eventHasRequestDiscriminator(event: Record): boolean { + return hasOwn(event, 'requestId'); +} + +function validateCheckpointEventShape(value: unknown): CheckpointRuntimeEvent { + const event = checkpointObject(value); + if (!event || typeof event.type !== 'string') { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Runtime event must be an object with a discriminator'); + } + + const exact = (keys: readonly string[]): void => checkpointExactEventKeys(event, keys); + const base = (): void => { + if (event.version !== 1 || typeof event.eventId !== 'number' || !Number.isSafeInteger(event.eventId) || event.eventId < 1 || + typeof event.sessionId !== 'string' || event.sessionId.length === 0 || typeof event.scope === 'undefined') { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', `Runtime event ${event.type} has invalid base fields`); + } + }; + + switch (event.type) { + case 'CatalogReconciliationRequested': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'requestId', 'requestOrdinal', 'expansionOwnerCheck', 'status']); + base(); + if (typeof event.requestId !== 'string' || typeof event.expansionOwnerCheck !== 'string' || event.status !== 'pending' || + typeof event.requestOrdinal !== 'number' || !Number.isSafeInteger(event.requestOrdinal) || event.requestOrdinal < 1) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Catalog request event has invalid fields'); + } + return event as unknown as CatalogReconciliationRequestedEvent; + case 'SubgraphExpanded': { + const nested = event.parentSubgraphInstanceId !== null; + exact(nested + ? ['version', 'type', 'eventId', 'sessionId', 'scope', 'expansionOwnerCheck', 'graphSemanticDigest', 'expansionSpecDigest', 'templateDigest', 'parentSubgraphInstanceId', 'expansionOwnerNodeInstanceId', 'catalogClaimRef', 'catalogClaimId', 'itemKey', 'subgraphInstanceId', 'nodeInstanceIdsByTemplateNode'] + : ['version', 'type', 'eventId', 'sessionId', 'scope', 'expansionOwnerCheck', 'graphSemanticDigest', 'expansionSpecDigest', 'templateDigest', 'parentSubgraphInstanceId', 'catalogClaimId', 'itemKey', 'subgraphInstanceId', 'nodeInstanceIdsByTemplateNode']); + base(); + if (typeof event.expansionOwnerCheck !== 'string' || typeof event.graphSemanticDigest !== 'string' || + typeof event.expansionSpecDigest !== 'string' || typeof event.templateDigest !== 'string' || + (nested && (typeof event.expansionOwnerNodeInstanceId !== 'string' || typeof event.catalogClaimRef !== 'string')) || + typeof event.catalogClaimId !== 'string' || typeof event.itemKey !== 'string' || + typeof event.subgraphInstanceId !== 'string' || !isRecord(event.nodeInstanceIdsByTemplateNode)) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Expanded subgraph event has invalid fields'); + } + return event as unknown as InstanceRuntimeEvent; + } + case 'ControllerItemClaimPublished': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'expansionOwnerCheck', 'expansionSpecDigest', 'catalogClaimId', 'itemKey', 'subgraphInstanceId', 'incarnation', 'claimId', 'claim', 'payload', 'payloadFingerprint', 'parentClaimIds']); + base(); + if (typeof event.expansionOwnerCheck !== 'string' || typeof event.expansionSpecDigest !== 'string' || typeof event.catalogClaimId !== 'string' || + typeof event.itemKey !== 'string' || typeof event.subgraphInstanceId !== 'string' || typeof event.incarnation !== 'number' || !Number.isSafeInteger(event.incarnation) || event.incarnation < 1 || + typeof event.claimId !== 'string' || typeof event.claim !== 'string' || typeof event.payloadFingerprint !== 'string' || + !Array.isArray(event.parentClaimIds)) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Controller item event has invalid fields'); + return event as unknown as InstanceRuntimeEvent; + case 'NodeGenerationInactivated': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'subgraphInstanceId', 'nodeInstanceId', 'nodeGenerationId', 'incarnation', 'outputClaimIds', 'reason']); + base(); + if (typeof event.subgraphInstanceId !== 'string' || typeof event.nodeInstanceId !== 'string' || typeof event.nodeGenerationId !== 'string' || + typeof event.incarnation !== 'number' || !Number.isSafeInteger(event.incarnation) || event.incarnation < 0 || !Array.isArray(event.outputClaimIds) || event.reason !== 'superseded') { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Generation inactivation event has invalid fields'); + } + return event as unknown as InstanceRuntimeEvent; + case 'NodeGenerationActivated': + exact(hasOwn(event, 'nestedExpansionCatalogClaimRef') + ? ['version', 'type', 'eventId', 'sessionId', 'scope', 'subgraphInstanceId', 'nodeInstanceId', 'nodeGenerationId', 'templateNodeKey', 'checkId', 'incarnation', 'itemFingerprint', 'executionConfigDigest', 'activeInputClaimIds', 'nestedExpansionCatalogClaimRef'] + : ['version', 'type', 'eventId', 'sessionId', 'scope', 'subgraphInstanceId', 'nodeInstanceId', 'nodeGenerationId', 'templateNodeKey', 'checkId', 'incarnation', 'itemFingerprint', 'executionConfigDigest', 'activeInputClaimIds']); + base(); + if (typeof event.subgraphInstanceId !== 'string' || typeof event.nodeInstanceId !== 'string' || typeof event.nodeGenerationId !== 'string' || typeof event.templateNodeKey !== 'string' || typeof event.checkId !== 'string' || + typeof event.incarnation !== 'number' || !Number.isSafeInteger(event.incarnation) || event.incarnation < 0 || typeof event.itemFingerprint !== 'string' || typeof event.executionConfigDigest !== 'string' || !Array.isArray(event.activeInputClaimIds) || + (hasOwn(event, 'nestedExpansionCatalogClaimRef') && typeof event.nestedExpansionCatalogClaimRef !== 'string')) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Generation activation event has invalid fields'); + return event as unknown as InstanceRuntimeEvent; + case 'SubgraphTombstoned': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'expansionOwnerCheck', 'sourceCatalogClaimId', 'itemKey', 'subgraphInstanceId', 'lastIncarnation', 'nodeGenerationIds', 'outputClaimIds']); + base(); + if (typeof event.expansionOwnerCheck !== 'string' || typeof event.sourceCatalogClaimId !== 'string' || typeof event.itemKey !== 'string' || typeof event.subgraphInstanceId !== 'string' || typeof event.lastIncarnation !== 'number' || !Number.isSafeInteger(event.lastIncarnation) || event.lastIncarnation < 0 || !Array.isArray(event.nodeGenerationIds) || !Array.isArray(event.outputClaimIds)) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Tombstone event has invalid fields'); + return event as unknown as InstanceRuntimeEvent; + case 'ManagedRunAcquisitionFailed': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'binding', 'failureCode']); + base(); + if (!isRecord(event.binding) || typeof event.failureCode !== 'string') throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Managed acquisition event has invalid fields'); + return event as unknown as InstanceRuntimeEvent; + case 'ManagedRunAcquired': + case 'ManagedRunStarted': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'binding']); + base(); + if (!isRecord(event.binding)) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Managed lifecycle event has invalid binding'); + return event as unknown as InstanceRuntimeEvent; + case 'ManagedRunCancelRequested': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'binding', 'reason']); + base(); + if (!isRecord(event.binding) || event.reason !== 'deadline') throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Managed cancel event has invalid fields'); + return event as unknown as InstanceRuntimeEvent; + case 'ManagedRunTerminated': + exact(['version', 'type', 'eventId', 'sessionId', 'scope', 'binding', 'cleanupStatus', 'controllerDecision', 'failureCode']); + base(); + if (!isRecord(event.binding) || (event.cleanupStatus !== 'clean' && event.cleanupStatus !== 'unverified') || (event.controllerDecision !== 'completed' && event.controllerDecision !== 'failed') || (event.failureCode !== null && typeof event.failureCode !== 'string')) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Managed terminal event has invalid fields'); + return event as unknown as InstanceRuntimeEvent; + case 'AttemptStarted': + case 'CheckScheduled': + case 'AttemptCompleted': + case 'AttemptFailed': { + const node = eventHasNodeDiscriminator(event); + const request = eventHasRequestDiscriminator(event); + if (node && request) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', `${event.type} cannot carry request and node discriminators`); + const keys: string[] = [...ATTEMPT_BASE_KEYS]; + if (node) keys.push('nodeInstanceId', 'nodeGenerationId'); + else if (request) keys.push('requestId'); + if (event.type === 'CheckScheduled') keys.push('claimIds'); + if (event.type === 'AttemptCompleted' && request) keys.push('catalogClaimId'); + if (event.type === 'AttemptFailed') keys.push('reason'); + exact(keys); + base(); + if (typeof event.checkId !== 'string' || typeof event.attemptId !== 'string' || typeof event.fence !== 'number' || !Number.isSafeInteger(event.fence) || event.fence < 1 || + (node && (typeof event.nodeInstanceId !== 'string' || typeof event.nodeGenerationId !== 'string')) || + (request && typeof event.requestId !== 'string') || (event.type === 'CheckScheduled' && !Array.isArray(event.claimIds)) || + (event.type === 'AttemptCompleted' && request && typeof event.catalogClaimId !== 'string') || (event.type === 'AttemptFailed' && typeof event.reason !== 'string')) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', `${event.type} has invalid fields`); + } + return event as unknown as CheckpointRuntimeEvent; + } + case 'ClaimPublished': { + const node = eventHasNodeDiscriminator(event); + if (eventHasRequestDiscriminator(event)) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'ClaimPublished cannot carry requestId'); + const keys = node + ? [...ATTEMPT_BASE_KEYS, 'nodeInstanceId', 'nodeGenerationId', 'claimId', 'claim', 'payload', 'payloadFingerprint', 'producerCheckId', 'parentClaimIds'] + : [...ATTEMPT_BASE_KEYS, 'claimId', 'claim', 'payload', 'payloadFingerprint', 'producerCheckId', 'parentClaimIds']; + exact(keys); + base(); + if (typeof event.checkId !== 'string' || typeof event.attemptId !== 'string' || typeof event.fence !== 'number' || !Number.isSafeInteger(event.fence) || event.fence < 1 || + typeof event.claimId !== 'string' || typeof event.claim !== 'string' || typeof event.payloadFingerprint !== 'string' || typeof event.producerCheckId !== 'string' || !Array.isArray(event.parentClaimIds) || + (node && (typeof event.nodeInstanceId !== 'string' || typeof event.nodeGenerationId !== 'string'))) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Claim publication has invalid fields'); + return event as unknown as CheckpointRuntimeEvent; + } + default: + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', `Unknown runtime event type ${event.type}`); + } +} + +function routeCheckpointEvent(event: CheckpointRuntimeEvent): { claim: boolean; instance: boolean } { + if (event.type === 'ClaimPublished') return 'nodeGenerationId' in event ? { claim: false, instance: true } : { claim: true, instance: false }; + if (event.type === 'AttemptStarted' || event.type === 'CheckScheduled' || event.type === 'AttemptCompleted' || event.type === 'AttemptFailed') { + if ('nodeGenerationId' in event) return { claim: false, instance: true }; + if ('requestId' in event) return { claim: true, instance: true }; + return { claim: true, instance: false }; + } + return { claim: false, instance: true }; +} + +function checkpointAuthorityFailure(message: string): never { + throw new GraphJournalCheckpointError('CHECKPOINT_PLAN_AUTHORITY_MISMATCH', message); +} + +function expansionForCheckpoint( + plan: ClaimPlan, + owner: string, + nested: boolean +): CompiledExpansion { + const expansion = nested + ? plan.expansionPlan?.byNestedOwner[owner] + : plan.expansionPlan?.byOwner[owner]; + if (!expansion) checkpointAuthorityFailure(`Unknown compiled expansion owner ${owner}`); + return expansion; +} + +function validateCheckpointPlanAuthority( + event: InstanceRuntimeEvent, + plan: ClaimPlan, + claimProjection: ClaimProjection, + instanceProjection: InstanceProjection +): void { + const expansionPlan = plan.expansionPlan; + if (!expansionPlan?.active) checkpointAuthorityFailure('Checkpoint requires an active expansion plan'); + const asRecord = event as unknown as Record; + if (event.type === 'CatalogReconciliationRequested') { + expansionForCheckpoint(plan, event.expansionOwnerCheck, false); + return; + } + if (event.type === 'SubgraphExpanded') { + const nested = event.parentSubgraphInstanceId !== null; + const expansion = expansionForCheckpoint(plan, event.expansionOwnerCheck, nested); + if (event.graphSemanticDigest !== expansion.graphSemanticDigest || + event.expansionSpecDigest !== expansion.expansionSpecDigest || + event.templateDigest !== expansion.templateDigest || + event.catalogClaimId.length === 0 || event.itemKey.length === 0) { + checkpointAuthorityFailure('Expanded subgraph does not match the compiled expansion authority'); + } + const templateKeys = [...expansion.template.templateNodeKeys].sort(); + const eventKeys = Object.keys(event.nodeInstanceIdsByTemplateNode).sort(); + if (canonicalJson(templateKeys) !== canonicalJson(eventKeys)) { + checkpointAuthorityFailure('Expanded subgraph node key set does not match the compiled template'); + } + if (!nested) { + const catalog = claimProjection.claims[event.catalogClaimId]; + if (!catalog || claimProjection.activeClaimIdsByRef[expansion.catalogClaimRef] !== event.catalogClaimId || catalog.claim !== expansion.catalogClaimRef || catalog.producerCheckId !== event.expansionOwnerCheck || catalog.scope.length !== 0) { + checkpointAuthorityFailure('Root expansion catalog claim is not the exact projected authority'); + } + } + return; + } + if (event.type === 'ControllerItemClaimPublished') { + const instance = instanceProjection.instancesById[event.subgraphInstanceId]; + if (!instance) checkpointAuthorityFailure('Controller item claim references an unknown instance'); + const nested = !!instance.parentSubgraphInstanceId; + const expansion = expansionForCheckpoint(plan, event.expansionOwnerCheck, nested); + if (instance.expansionOwnerCheck !== event.expansionOwnerCheck || + event.expansionSpecDigest !== expansion.expansionSpecDigest || event.claim !== expansion.itemClaimRef) { + checkpointAuthorityFailure('Controller item claim does not match the compiled expansion authority'); + } + if (!nested) { + const catalog = claimProjection.claims[event.catalogClaimId]; + if (!catalog || claimProjection.activeClaimIdsByRef[expansion.catalogClaimRef] !== event.catalogClaimId || catalog.claim !== expansion.catalogClaimRef) { + checkpointAuthorityFailure('Controller item catalog claim is not the exact active plan authority'); + } + } + try { expansion.itemValidator(event.payload); } catch (error) { + throw new GraphJournalCheckpointError('CHECKPOINT_PLAN_AUTHORITY_MISMATCH', 'Controller item payload violates the compiled item validator', { cause: error }); + } + return; + } + if (event.type === 'NodeGenerationActivated') { + const instance = instanceProjection.instancesById[event.subgraphInstanceId]; + if (!instance) checkpointAuthorityFailure('Generation activation references an unknown instance'); + const expansion = expansionForCheckpoint(plan, instance.expansionOwnerCheck, !!instance.parentSubgraphInstanceId); + const node = expansion.template.nodesByKey[event.templateNodeKey]; + const nestedOwner = qualifiedNestedExpansionOwner(expansion.template.name, event.templateNodeKey); + const nestedExpansion = expansionPlan.byNestedOwner[nestedOwner]; + if (!node || event.executionConfigDigest !== node.executionConfigDigest || + (nestedExpansion ? event.nestedExpansionCatalogClaimRef !== nestedExpansion.catalogClaimRef : hasOwn(asRecord, 'nestedExpansionCatalogClaimRef'))) { + checkpointAuthorityFailure('Generation activation does not match the compiled template node authority'); + } + return; + } + if ('nodeGenerationId' in event && event.type === 'ClaimPublished') { + const generation = instanceProjection.generationsById[event.nodeGenerationId]; + if (!generation) checkpointAuthorityFailure('Generated claim references an unknown generation'); + const expansion = expansionForCheckpoint(plan, instanceProjection.instancesById[generation.subgraphInstanceId].expansionOwnerCheck, !!instanceProjection.instancesById[generation.subgraphInstanceId].parentSubgraphInstanceId); + const node = expansion.template.nodesByKey[generation.templateNodeKey]; + if (!node || !node.emissions.some(emission => emission.claim === event.claim)) checkpointAuthorityFailure('Generated claim is not declared by its compiled template node'); + try { plan.validatorsByClaim[event.claim](event.payload); } catch (error) { + throw new GraphJournalCheckpointError('CHECKPOINT_PLAN_AUTHORITY_MISMATCH', 'Generated claim payload violates the compiled claim validator', { cause: error }); + } + } +} + +function checkpointBody(value: Record): Record { + return { + kind: value.kind, + version: value.version, + sessionId: value.sessionId, + graphSemanticDigest: value.graphSemanticDigest, + frontier: value.frontier, + events: value.events, + }; +} + +function parseGraphCheckpoint(input: unknown): GraphJournalCheckpointV1 { + try { + canonicalJson(input); + } catch (error) { + throw checkpointWrap('INVALID_CHECKPOINT_ENVELOPE', 'Checkpoint is not canonical JSON', error); + } + const value = checkpointObject(input); + if (!value || !checkpointHasExactKeys(value, ['kind', 'version', 'sessionId', 'graphSemanticDigest', 'frontier', 'events', 'integrity'])) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', 'Checkpoint envelope has unknown or missing fields'); + } + if (value.kind !== 'visor.graph-journal-checkpoint' || value.version !== 1) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', 'Unsupported checkpoint kind or version'); + } + checkpointString(value.sessionId, 'Checkpoint sessionId'); + checkpointString(value.graphSemanticDigest, 'Checkpoint graphSemanticDigest'); + const frontier = checkpointObject(value.frontier); + const integrity = checkpointObject(value.integrity); + if (!frontier || !checkpointHasExactKeys(frontier, ['eventCount', 'lastEventId']) || !integrity || !checkpointHasExactKeys(integrity, ['algorithm', 'digest'])) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', 'Checkpoint frontier or integrity shape is invalid'); + } + checkpointSafeInteger(frontier.eventCount, 'frontier.eventCount'); + checkpointSafeInteger(frontier.lastEventId, 'frontier.lastEventId'); + if (integrity.algorithm !== 'sha256' || typeof integrity.digest !== 'string' || !CHECKPOINT_SHA256.test(integrity.digest)) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', 'Checkpoint integrity algorithm or digest is invalid'); + } + if (!Array.isArray(value.events)) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_ENVELOPE', 'Checkpoint events must be an array'); + const expectedDigest = sha256Canonical(checkpointBody(value)); + if (integrity.digest !== expectedDigest) { + throw new GraphJournalCheckpointError('CHECKPOINT_INTEGRITY_MISMATCH', 'Checkpoint integrity digest does not match its canonical body'); + } + return value as unknown as GraphJournalCheckpointV1; +} + +function validateCheckpointPrefix(checkpoint: GraphJournalCheckpointV1): readonly CheckpointRuntimeEvent[] { + const frontier = checkpoint.frontier; + const rawEvents = checkpoint.events; + if (frontier.eventCount !== rawEvents.length || frontier.lastEventId !== (rawEvents.length === 0 ? 0 : rawEvents.length)) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Checkpoint frontier does not describe the event prefix'); + } + const events = rawEvents.map(validateCheckpointEventShape); + for (const [index, event] of events.entries()) { + if (event.eventId !== index + 1) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Checkpoint event IDs must be contiguous from 1'); + if (event.sessionId !== checkpoint.sessionId) throw new GraphJournalCheckpointError('CHECKPOINT_SESSION_MISMATCH', 'Checkpoint event session differs from its envelope session'); + } + return events; +} + +function reconstructCheckpointAllocators( + events: readonly CheckpointRuntimeEvent[] +): { nextFence: number; attemptOrdinals: Map; requestOrdinals: Map } { + let nextFence = 0; + const attemptOrdinals = new Map(); + const requestOrdinals = new Map(); + const generatedStarts = new Set(); + for (const event of events) { + if (event.type === 'CatalogReconciliationRequested') { + const prior = requestOrdinals.get(event.expansionOwnerCheck) || 0; + if (event.requestOrdinal !== prior + 1 || event.requestId !== deriveCatalogRequestId({ + sessionId: event.sessionId, + expansionOwnerCheck: event.expansionOwnerCheck, + ordinal: event.requestOrdinal, + })) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Catalog request ordinal is not the next derived ordinal'); + } + requestOrdinals.set(event.expansionOwnerCheck, event.requestOrdinal); + } + if (event.type !== 'AttemptStarted') continue; + nextFence++; + if (event.fence !== nextFence) throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Attempt fences must be one contiguous global sequence'); + if ('nodeGenerationId' in event) { + const generatedKey = canonicalJson({ nodeGenerationId: event.nodeGenerationId, scope: event.scope }); + if (generatedStarts.has(generatedKey) || event.attemptId !== sha256Canonical({ nodeGenerationId: event.nodeGenerationId, ordinal: 1 })) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Generated attempt identity or ordinal is invalid'); + } + generatedStarts.add(generatedKey); + continue; + } + const authority = { sessionId: event.sessionId, checkId: event.checkId, scope: event.scope }; + const key = canonicalJson(authority); + const ordinal = (attemptOrdinals.get(key) || 0) + 1; + if (event.attemptId !== sha256Canonical({ ...authority, ordinal })) { + throw new GraphJournalCheckpointError('INVALID_CHECKPOINT_PREFIX', 'Attempt identity is not derived from its reconstructed ordinal'); + } + attemptOrdinals.set(key, ordinal); + } + return { nextFence, attemptOrdinals, requestOrdinals }; +} + +function ensureCheckpointQuiescent(claimProjection: ClaimProjection, instanceProjection: InstanceProjection): void { + if (Object.values(claimProjection.attempts).some(attempt => attempt.status === 'started')) { + throw new GraphJournalCheckpointError('CHECKPOINT_NOT_QUIESCENT', 'Checkpoint contains a started root or catalog attempt'); + } + if (Object.values(instanceProjection.requestsById).some(request => request.status === 'pending' || request.status === 'running')) { + throw new GraphJournalCheckpointError('CHECKPOINT_NOT_QUIESCENT', 'Checkpoint contains a pending or running catalog request'); + } + if (Object.values(instanceProjection.generationsById).some(generation => generation.status === 'ready' || generation.status === 'running')) { + throw new GraphJournalCheckpointError('CHECKPOINT_NOT_QUIESCENT', 'Checkpoint contains a ready or running generation'); + } + if (Object.values(instanceProjection.managedRunsByAttemptId).some(run => run.status === 'acquired' || run.status === 'started' || run.status === 'cancel_requested')) { + throw new GraphJournalCheckpointError('CHECKPOINT_NOT_QUIESCENT', 'Checkpoint contains a nonterminal managed run'); + } +} + export interface JournalEntry { commitId: number; sessionId: string; @@ -20,6 +540,104 @@ export interface JournalEntry { export class ExecutionJournal { private commit = 0; private entries: JournalEntry[] = []; + private runtimeEvents: Array = []; + private claimProjection: ClaimProjection = createInitialClaimProjection(); + private instanceProjection: InstanceProjection = createInitialInstanceProjection(); + private nextFence = 0; + private attemptOrdinals = new Map(); + private requestOrdinals = new Map(); + + constructor(private readonly claimPlan?: ClaimPlan) {} + + /** Export the immutable Graph-v2 runtime prefix and its canonical integrity digest. */ + exportGraphCheckpoint(sessionId: string): GraphJournalCheckpointV1 { + checkpointString(sessionId, 'sessionId'); + const plan = this.requireClaimPlan(); + if (!plan.expansionPlan?.active) { + throw new GraphJournalCheckpointError('CHECKPOINT_GRAPH_MISMATCH', 'Graph journal checkpoints require an active expansion plan'); + } + const events = immutableCanonicalValue(this.runtimeEvents) as readonly CheckpointRuntimeEvent[]; + if (events.some(event => event.sessionId !== sessionId)) { + throw new GraphJournalCheckpointError('CHECKPOINT_SESSION_MISMATCH', 'Runtime event session differs from export session'); + } + const body = { + kind: 'visor.graph-journal-checkpoint' as const, + version: 1 as const, + sessionId, + graphSemanticDigest: plan.expansionPlan.graphSemanticDigest, + frontier: { eventCount: events.length, lastEventId: events.length === 0 ? 0 : events.length }, + events, + }; + return immutableCanonicalValue({ + ...body, + integrity: { algorithm: 'sha256' as const, digest: sha256Canonical(body) }, + }); + } + + /** Restore a fresh journal only after complete envelope, authority, replay, and frontier validation. */ + static restoreGraphCheckpoint(claimPlan: ClaimPlan, input: unknown): ExecutionJournal { + const checkpoint = parseGraphCheckpoint(input); + if (!claimPlan || !claimPlan.active || !claimPlan.expansionPlan?.active) { + throw new GraphJournalCheckpointError('CHECKPOINT_GRAPH_MISMATCH', 'Checkpoint restore requires an active claim and expansion plan'); + } + if (checkpoint.graphSemanticDigest !== claimPlan.expansionPlan.graphSemanticDigest) { + throw new GraphJournalCheckpointError('CHECKPOINT_GRAPH_MISMATCH', 'Checkpoint graph digest does not match the current compiled plan'); + } + + const validatedEvents = validateCheckpointPrefix(checkpoint); + const events = immutableCanonicalValue(validatedEvents) as readonly CheckpointRuntimeEvent[]; + const claimEvents: ClaimRuntimeEvent[] = []; + const instanceEvents: InstanceRuntimeEvent[] = []; + let claimPrefix = createInitialClaimProjection(); + let instancePrefix = createInitialInstanceProjection(); + for (const event of events) { + const route = routeCheckpointEvent(event); + if (route.claim) { + claimEvents.push(event as ClaimRuntimeEvent); + try { + claimPrefix = reduceClaimEvent(claimPrefix, event as ClaimRuntimeEvent, claimPlan); + } catch (error) { + throw checkpointWrap('INVALID_CHECKPOINT_PREFIX', 'Checkpoint root claim replay failed', error); + } + } + if (route.instance) { + instanceEvents.push(event as InstanceRuntimeEvent); + validateCheckpointPlanAuthority(event as InstanceRuntimeEvent, claimPlan, claimPrefix, instancePrefix); + try { + // Preview each event to make plan authority checks resolve against the exact + // projection prefix; replayInstanceEvents below remains the final reducer. + instancePrefix = reduceInstanceEvent(instancePrefix, event as InstanceRuntimeEvent); + } catch (error) { + throw checkpointWrap('INVALID_CHECKPOINT_PREFIX', 'Checkpoint instance replay failed', error); + } + } + } + + let claimProjection: ClaimProjection; + let instanceProjection: InstanceProjection; + try { + claimProjection = replayClaimEvents(claimEvents, claimPlan); + } catch (error) { + throw checkpointWrap('INVALID_CHECKPOINT_PREFIX', 'Checkpoint root claim replay failed', error); + } + try { + instanceProjection = replayInstanceEvents(instanceEvents); + } catch (error) { + throw checkpointWrap('INVALID_CHECKPOINT_PREFIX', 'Checkpoint instance replay failed', error); + } + ensureCheckpointQuiescent(claimProjection, instanceProjection); + const allocators = reconstructCheckpointAllocators(events); + + const restored = new ExecutionJournal(claimPlan); + // Keep the journal's internal lane appendable while retaining immutable event values. + restored.runtimeEvents = events.map(event => immutableCanonicalValue(event)) as Array; + restored.claimProjection = immutableCanonicalValue(claimProjection); + restored.instanceProjection = immutableCanonicalValue(instanceProjection); + restored.nextFence = allocators.nextFence; + restored.attemptOrdinals = allocators.attemptOrdinals; + restored.requestOrdinals = allocators.requestOrdinals; + return restored; + } beginSnapshot(): number { return this.commit; @@ -51,6 +669,1168 @@ export class ExecutionJournal { ); } + private requireClaimPlan(): ClaimPlan { + if (!this.claimPlan?.active) { + throw new ClaimKernelError('CLAIM_MODE_INACTIVE', 'Runtime claim journal is inactive'); + } + return this.claimPlan; + } + + private appendRuntimeEvent(event: T): T { + const plan = this.requireClaimPlan(); + const stored = immutableRuntimeEvent(event); + const projected = reduceClaimEvent(this.claimProjection, stored, plan); + this.runtimeEvents.push(stored); + this.claimProjection = projected; + return stored; + } + + private nextRuntimeEventId(): number { + return Math.max(this.claimProjection.lastEventId, this.instanceProjection.lastEventId) + 1; + } + + private appendInstanceEvent(event: T): T { + this.requireClaimPlan(); + const stored = immutableInstanceEvent(event); + const projected = reduceInstanceEvent(this.instanceProjection, stored); + this.runtimeEvents.push(stored); + this.instanceProjection = projected; + return stored; + } + + requestCatalogReconciliation(input: { + sessionId: string; + ownerCheck: string; + }): CatalogReconciliationRequestedEvent { + const expansion = this.requireClaimPlan().expansionPlan?.byOwner[input.ownerCheck]; + if (!expansion) { + throw new ClaimKernelError('UNKNOWN_EXPANSION_OWNER', `Unknown expansion owner ${input.ownerCheck}`); + } + const ordinal = (this.requestOrdinals.get(input.ownerCheck) || 0) + 1; + this.requestOrdinals.set(input.ownerCheck, ordinal); + return this.appendInstanceEvent({ + version: 1, + type: 'CatalogReconciliationRequested', + eventId: this.nextRuntimeEventId(), + sessionId: input.sessionId, + scope: [], + requestId: deriveCatalogRequestId({ + sessionId: input.sessionId, + expansionOwnerCheck: input.ownerCheck, + ordinal, + }), + requestOrdinal: ordinal, + expansionOwnerCheck: input.ownerCheck, + status: 'pending', + }); + } + + getOldestPendingCatalogRequest() { + const id = this.instanceProjection.requestOrder.find( + requestId => this.instanceProjection.requestsById[requestId].status === 'pending' + ); + return id ? this.instanceProjection.requestsById[id] : undefined; + } + + startCatalogRequestAttempt(requestId: string): CatalogAttemptStartedEvent { + const request = this.instanceProjection.requestsById[requestId]; + if (!request || request.status !== 'pending') throw new ClaimKernelError('INVALID_REQUEST_STATE', `Request ${requestId} is not pending`); + const scope: ScopePath & RootScopePath = []; + const authority = { sessionId: request.sessionId, checkId: request.expansionOwnerCheck, scope }; + const ordinalKey = canonicalJson(authority); const ordinal=(this.attemptOrdinals.get(ordinalKey)||0)+1; + this.attemptOrdinals.set(ordinalKey,ordinal); const fence=++this.nextFence; + const event = immutableCanonicalValue({version:1,type:'AttemptStarted',eventId:this.nextRuntimeEventId(),...authority,attemptId:sha256Canonical({...authority,ordinal}),fence,requestId}); + const claim = reduceClaimEvent(this.claimProjection,event,this.requireClaimPlan()); + const instance = reduceInstanceEvent(this.instanceProjection,event); + this.runtimeEvents.push(event); this.claimProjection=claim; this.instanceProjection=instance; + return event; + } + + scheduleCatalogRequestAttempt(input: CatalogScheduleAuthority): CatalogCheckScheduledEvent { + const request = this.instanceProjection.requestsById[input.requestId]; + if (!request) { + throw new ClaimKernelError('UNKNOWN_REQUEST', `Unknown catalog request ${input.requestId}`); + } + const scope: ScopePath & RootScopePath = []; + const claimIds = exactActiveClaimIds( + this.requireClaimPlan(), + this.claimProjection, + request.expansionOwnerCheck + ); + const event = immutableCanonicalValue({ + version: 1, + type: 'CheckScheduled', + eventId: this.nextRuntimeEventId(), + sessionId: request.sessionId, + checkId: request.expansionOwnerCheck, + scope, + requestId: request.requestId, + attemptId: input.attemptId, + fence: input.fence, + claimIds: [...claimIds], + }); + const claim=reduceClaimEvent(this.claimProjection,event,this.requireClaimPlan()); + const instance=reduceInstanceEvent(this.instanceProjection,event); + this.runtimeEvents.push(event); this.claimProjection=claim; this.instanceProjection=instance; + return event; + } + + startGeneratedAttempt(nodeGenerationId: string): GeneratedAttemptStartedEvent { + const generation = this.instanceProjection.generationsById[nodeGenerationId]; + if (!generation || generation.status !== 'ready') { + throw new ClaimKernelError('GENERATION_NOT_READY', `Generation ${nodeGenerationId} is not ready`); + } + const fence = ++this.nextFence; + const ordinalKey = canonicalJson({ nodeGenerationId, scope: generation.scope }); + const ordinal = (this.attemptOrdinals.get(ordinalKey) || 0) + 1; + this.attemptOrdinals.set(ordinalKey, ordinal); + return this.appendInstanceEvent({ + version: 1, + type: 'AttemptStarted', + eventId: this.nextRuntimeEventId(), + sessionId: this.instanceProjection.instancesById[generation.subgraphInstanceId].sessionId, + checkId: generation.checkId, + scope: generation.scope, + attemptId: sha256Canonical({ nodeGenerationId, ordinal }), + fence, + nodeInstanceId: generation.nodeInstanceId, + nodeGenerationId, + }); + } + + scheduleGeneratedAttempt(input: GeneratedScheduleAuthority): GeneratedCheckScheduledEvent { + const generation = this.instanceProjection.generationsById[input.nodeGenerationId]; + if (!generation) { + throw new ClaimKernelError( + 'UNKNOWN_GENERATION', + `Unknown generation ${input.nodeGenerationId}` + ); + } + const instance = this.instanceProjection.instancesById[generation.subgraphInstanceId]; + return this.appendInstanceEvent({ + version: 1, + type: 'CheckScheduled', + eventId: this.nextRuntimeEventId(), + sessionId: instance.sessionId, + checkId: generation.checkId, + scope: generation.scope, + attemptId: input.attemptId, + fence: input.fence, + nodeInstanceId: generation.nodeInstanceId, + nodeGenerationId: generation.nodeGenerationId, + claimIds: [...generation.activeInputClaimIds], + }); + } + + private compiledExpansionForInstance(subgraphInstanceId: string): CompiledExpansion { + const instance = this.instanceProjection.instancesById[subgraphInstanceId]; + if (!instance) { + throw new ClaimKernelError('UNKNOWN_INSTANCE', `Unknown instance ${subgraphInstanceId}`); + } + const expansionPlan = this.requireClaimPlan().expansionPlan!; + const expansion = instance.parentSubgraphInstanceId + ? expansionPlan.byNestedOwner[instance.expansionOwnerCheck] + : expansionPlan.byOwner[instance.expansionOwnerCheck]; + if (!expansion || expansion.expansionSpecDigest !== instance.expansionSpecDigest) { + throw new ClaimKernelError( + 'INVALID_EXPANSION_AUTHORITY', + `Instance ${subgraphInstanceId} is not bound to one exact compiled expansion` + ); + } + return expansion; + } + + getGeneratedExecution(nodeGenerationId: string) { + const generation = this.instanceProjection.generationsById[nodeGenerationId]; + if (!generation) throw new ClaimKernelError('UNKNOWN_GENERATION', `Unknown generation ${nodeGenerationId}`); + const instance = this.instanceProjection.instancesById[generation.subgraphInstanceId]; + const expansion = this.compiledExpansionForInstance(instance.subgraphInstanceId); + const node = expansion.template.nodesByKey[generation.templateNodeKey]; + const claims: Record = {}; + for (const consumption of node.consumptions) { + const claim = generation.activeInputClaimIds + .map(id => this.instanceProjection.claimsById[id]) + .find(candidate => candidate?.claim === consumption.claim); + if (!claim) throw new ClaimKernelError('CLAIM_NOT_READY', `Missing generated input ${consumption.claim}`); + if ( + (claim.kind === 'controller-item' && !claim.controllerCatalogClaimId) || + (claim.kind === 'generated-output' && + (!claim.producerAttemptId || claim.producerFence === undefined)) + ) { + throw new ClaimKernelError( + 'INVALID_CLAIM_PROVENANCE', + `Claim ${claim.claimId} lacks authoritative producer provenance` + ); + } + const provenance = claim.kind === 'controller-item' + ? { + provenance: 'controller' as const, + catalogClaimId: claim.controllerCatalogClaimId as string, + incarnation: claim.incarnation, + } + : { + provenance: 'attempt' as const, + attemptId: claim.producerAttemptId as string, + fence: claim.producerFence as number, + }; + claims[consumption.as] = Object.freeze({ + claimId: claim.claimId, + claim: claim.claim, + payload: claim.payload, + payloadFingerprint: claim.payloadFingerprint, + producerCheckId: claim.producerCheckId, + scope: claim.scope, + parentClaimIds: claim.parentClaimIds, + ...provenance, + }); + } + return Object.freeze({ generation, node, claims: Object.freeze(claims) }); + } + + deriveManagedRunBinding(attempt: GeneratedAttemptStartedEvent): ManagedRunBindingV1 { + const generation = this.instanceProjection.generationsById[attempt.nodeGenerationId]; + const instance = generation + ? this.instanceProjection.instancesById[generation.subgraphInstanceId] + : undefined; + if ( + !generation || + !instance || + generation.status !== 'running' || + !generation.scheduled || + generation.attemptId !== attempt.attemptId || + generation.fence !== attempt.fence || + generation.nodeInstanceId !== attempt.nodeInstanceId || + this.instanceProjection.attemptBindingsById[attempt.attemptId] !== + generation.nodeGenerationId || + attempt.sessionId !== instance.sessionId || + attempt.checkId !== generation.checkId || + canonicalJson(attempt.scope) !== canonicalJson(generation.scope) || + attempt.nodeGenerationId !== generation.nodeGenerationId + ) { + throw new ClaimKernelError( + 'INVALID_MANAGED_BINDING', + `Attempt ${attempt.attemptId} is not the current scheduled generated attempt` + ); + } + const authority: Omit = { + sessionId: instance.sessionId, + checkId: generation.checkId, + scope: generation.scope, + nodeInstanceId: generation.nodeInstanceId, + nodeGenerationId: generation.nodeGenerationId, + attemptId: generation.attemptId!, + fence: generation.fence!, + }; + return immutableCanonicalValue({ + managedRunId: deriveManagedRunId(authority), + ...authority, + }); + } + + private appendInstanceEventBatch(events: readonly InstanceRuntimeEvent[]): void { + this.requireClaimPlan(); + const stored = events.map(event => immutableInstanceEvent(event)); + const projected = reduceInstanceEventBatch(this.instanceProjection, stored); + this.runtimeEvents.push(...stored); + this.instanceProjection = projected; + } + + failManagedRunAcquisition(input: { + attempt: GeneratedAttemptStartedEvent; + binding: ManagedRunBindingV1; + failureCode: ManagedRunAcquisitionFailureCode; + }): void { + const eventId = this.nextRuntimeEventId(); + this.appendInstanceEventBatch([ + { + version: 1, + type: 'ManagedRunAcquisitionFailed', + eventId, + sessionId: input.attempt.sessionId, + scope: input.attempt.scope, + binding: input.binding, + failureCode: input.failureCode, + }, + { + ...input.attempt, + type: 'AttemptFailed', + eventId: eventId + 1, + reason: input.failureCode, + }, + ]); + } + + recordManagedRunAcquired(binding: ManagedRunBindingV1): void { + this.appendInstanceEvent({ + version: 1, + type: 'ManagedRunAcquired', + eventId: this.nextRuntimeEventId(), + sessionId: binding.sessionId, + scope: binding.scope, + binding, + }); + } + + recordManagedRunStarted(binding: ManagedRunBindingV1): void { + this.appendInstanceEvent({ + version: 1, + type: 'ManagedRunStarted', + eventId: this.nextRuntimeEventId(), + sessionId: binding.sessionId, + scope: binding.scope, + binding, + }); + } + + recordManagedRunCancelRequested(binding: ManagedRunBindingV1): void { + this.appendInstanceEvent({ + version: 1, + type: 'ManagedRunCancelRequested', + eventId: this.nextRuntimeEventId(), + sessionId: binding.sessionId, + scope: binding.scope, + binding, + reason: 'deadline', + }); + } + + failManagedGeneratedAttempt(input: { + attempt: GeneratedAttemptStartedEvent; + binding: ManagedRunBindingV1; + cleanupStatus: ManagedRunCleanupStatus; + failureCode: ManagedRunFailureCode; + }): void { + const eventId = this.nextRuntimeEventId(); + this.appendInstanceEventBatch([ + { + version: 1, + type: 'ManagedRunTerminated', + eventId, + sessionId: input.attempt.sessionId, + scope: input.attempt.scope, + binding: input.binding, + cleanupStatus: input.cleanupStatus, + controllerDecision: 'failed', + failureCode: input.failureCode, + }, + { + ...input.attempt, + type: 'AttemptFailed', + eventId: eventId + 1, + reason: input.failureCode, + }, + ]); + } + + completeGeneratedAttempt(input: { + attempt: GeneratedAttemptStartedEvent; + payload: unknown; + }): void { + const staged = this.stageGeneratedCompletion(input); + this.runtimeEvents.push(...staged.events); + this.instanceProjection = staged.projection; + } + + completeManagedGeneratedAttempt(input: { + attempt: GeneratedAttemptStartedEvent; + binding: ManagedRunBindingV1; + payload: unknown; + }): void { + const terminal = immutableInstanceEvent({ + version: 1, + type: 'ManagedRunTerminated', + eventId: this.nextRuntimeEventId(), + sessionId: input.binding.sessionId, + scope: input.binding.scope, + binding: input.binding, + cleanupStatus: 'clean', + controllerDecision: 'completed', + failureCode: null, + }); + const staged = this.stageGeneratedCompletion(input, [terminal]); + this.runtimeEvents.push(...staged.events); + this.instanceProjection = staged.projection; + } + + private stageGeneratedCompletion( + input: { attempt: GeneratedAttemptStartedEvent; payload: unknown }, + prefix: readonly InstanceRuntimeEvent[] = [] + ): { events: readonly InstanceRuntimeEvent[]; projection: InstanceProjection } { + const { attempt, payload } = input; + const before = this.instanceProjection; + const generation = before.generationsById[attempt.nodeGenerationId]; + const instance = before.instancesById[generation.subgraphInstanceId]; + const expansion = this.compiledExpansionForInstance(instance.subgraphInstanceId); + const node = expansion.template.nodesByKey[generation.templateNodeKey]; + const nestedOwner = qualifiedNestedExpansionOwner( + expansion.template.name, + generation.templateNodeKey + ); + const nestedExpansion = this.requireClaimPlan().expansionPlan!.byNestedOwner[nestedOwner]; + let staged = before; + const events: InstanceRuntimeEvent[] = []; + const stage = (event: InstanceRuntimeEvent): void => { + const stored = immutableInstanceEvent(event); + staged = reduceInstanceEvent(staged, stored); + events.push(stored); + }; + for (const event of prefix) stage(event); + const publications: GeneratedClaimPublishedEvent[] = []; + for (const emission of node.emissions) { + this.requireClaimPlan().validatorsByClaim[emission.claim](payload); + const immutablePayload = immutableCanonicalValue(payload); + const payloadFingerprint = sha256Canonical(immutablePayload); + const parentClaimIds = [...generation.activeInputClaimIds].sort(); + const eventId = + Math.max(this.claimProjection.lastEventId, staged.lastEventId) + publications.length + 1; + const published: GeneratedClaimPublishedEvent = { + version: 1, type: 'ClaimPublished', eventId, + sessionId: attempt.sessionId, checkId: attempt.checkId, scope: attempt.scope, + attemptId: attempt.attemptId, fence: attempt.fence, + nodeInstanceId: attempt.nodeInstanceId, nodeGenerationId: attempt.nodeGenerationId, + claim: emission.claim, payload: immutablePayload, payloadFingerprint, + producerCheckId: attempt.checkId, parentClaimIds, + claimId: sha256Canonical({ claim: emission.claim, payloadFingerprint, + producerCheckId: attempt.checkId, scope: attempt.scope, attemptId: attempt.attemptId, + fence: attempt.fence, parentClaimIds }), + }; + publications.push(published); + } + const nestedCatalogPublications = nestedExpansion + ? publications.filter(publication => + publication.claim === nestedExpansion.catalogClaimRef + ) + : []; + if (nestedExpansion && nestedCatalogPublications.length !== 1) { + throw new ClaimKernelError( + 'INVALID_NESTED_CATALOG_AUTHORITY', + `Nested expansion owner ${nestedOwner} requires exactly one catalog publication` + ); + } + for (const publication of publications) stage(publication); + if (nestedExpansion) { + const catalogPublication = nestedCatalogPublications[0]; + const reconciled = this.reconcileCatalog({ + sessionId: attempt.sessionId, + expansion: nestedExpansion, + payload: catalogPublication.payload, + catalogClaimId: catalogPublication.claimId, + startEventId: Math.max(this.claimProjection.lastEventId, staged.lastEventId) + 1, + projection: staged, + parentSubgraphInstanceId: instance.subgraphInstanceId, + expansionOwnerNodeInstanceId: generation.nodeInstanceId, + }); + events.push(...reconciled.events); + staged = reconciled.projection; + } + for (const nodeKey of expansion.template.topology) { + const candidate = expansion.template.nodesByKey[nodeKey]; + const nodeInstanceId = instance.nodeInstanceIdsByTemplateNode[nodeKey]; + if (staged.activeGenerationIdByNode[nodeInstanceId]) continue; + const dependenciesCompleted = candidate.dependencyNodeKeys.every(dependencyNodeKey => { + const dependencyNodeId = instance.nodeInstanceIdsByTemplateNode[dependencyNodeKey]; + const dependencyGenerationId = staged.activeGenerationIdByNode[dependencyNodeId]; + const isCompletingGeneration = + dependencyGenerationId === generation.nodeGenerationId && + generation.nodeInstanceId === attempt.nodeInstanceId && + generation.status === 'running' && + generation.scheduled && + generation.attemptId === attempt.attemptId && + generation.fence === attempt.fence; + return ( + dependencyGenerationId !== undefined && + (isCompletingGeneration || + staged.generationsById[dependencyGenerationId]?.status === 'completed') + ); + }); + if (!dependenciesCompleted) continue; + const inputIds: string[] = []; + let ready = true; + for (const consumption of candidate.consumptions) { + const claims = Object.values(staged.claimsById) + .filter(value => + value.active && + value.subgraphInstanceId === instance.subgraphInstanceId && + value.incarnation === instance.incarnation && + value.claim === consumption.claim + ) + .sort((left, right) => left.claimId.localeCompare(right.claimId)); + if (claims.length !== 1) { + ready = false; + break; + } + inputIds.push(claims[0].claimId); + } + if (!ready) continue; + inputIds.sort(); + const item = instance.activeItemClaimId + ? staged.claimsById[instance.activeItemClaimId] + : undefined; + if (!item?.active) { + throw new ClaimKernelError( + 'INACTIVE_ITEM_CLAIM', + `Instance ${instance.subgraphInstanceId} lacks an active item claim` + ); + } + const nodeGenerationId = deriveNodeGenerationId({ nodeInstanceId, + incarnation: instance.incarnation, itemFingerprint: item.payloadFingerprint, + executionConfigDigest: candidate.executionConfigDigest, activeInputClaimIds: inputIds }); + const nestedCatalogClaimRef = this.requireClaimPlan().expansionPlan!.byNestedOwner[ + qualifiedNestedExpansionOwner(expansion.template.name, nodeKey) + ]?.catalogClaimRef; + stage({ version: 1, type: 'NodeGenerationActivated', + eventId: Math.max(this.claimProjection.lastEventId, staged.lastEventId) + 1, + sessionId: attempt.sessionId, scope: instance.scope, + subgraphInstanceId: instance.subgraphInstanceId, nodeInstanceId, nodeGenerationId, + templateNodeKey: nodeKey, checkId: nodeKey, incarnation: instance.incarnation, + itemFingerprint: item.payloadFingerprint, executionConfigDigest: candidate.executionConfigDigest, + activeInputClaimIds: inputIds, + ...(nestedCatalogClaimRef + ? { nestedExpansionCatalogClaimRef: nestedCatalogClaimRef } + : {}) }); + } + stage({ ...attempt, type: 'AttemptCompleted', + eventId: Math.max(this.claimProjection.lastEventId, staged.lastEventId) + 1 }); + return { + events, + projection: reduceInstanceEventBatch(before, events), + }; + } + + failGeneratedAttempt(attempt: GeneratedAttemptStartedEvent, reason: string): void { + this.appendInstanceEvent({ ...attempt, type: 'AttemptFailed', reason, + eventId: this.nextRuntimeEventId() }); + } + + queryReadyWork(): readonly NodeGenerationProjection[] { + return queryReadyGenerations(this.instanceProjection); + } + + getInstanceProjection(): InstanceProjection { + return immutableCanonicalValue(this.instanceProjection); + } + + getExpansionCoverageProjection(requestId: string): ExpansionCoverageProjection { + const request = this.instanceProjection.requestsById[requestId]; + const expansion = request + ? this.requireClaimPlan().expansionPlan?.byOwner[request.expansionOwnerCheck] + : undefined; + if (!expansion) { + throw new ClaimKernelError('UNKNOWN_COVERAGE_REQUEST', `Unknown coverage request ${requestId}`); + } + return projectExpansionCoverage(this.claimProjection, this.instanceProjection, expansion, requestId); + } + + getExpansionCoverageRequestIds(ownerCheck?: string): readonly string[] { + return Object.freeze(this.instanceProjection.requestOrder.filter(requestId => + ownerCheck === undefined || + this.instanceProjection.requestsById[requestId].expansionOwnerCheck === ownerCheck + )); + } + + replayExpansionCoverageProjection(requestId: string): ExpansionCoverageProjection { + const instanceProjection = this.replayInstanceProjection(); + const claimProjection = this.replayClaimProjection(); + const request = instanceProjection.requestsById[requestId]; + const expansion = request + ? this.requireClaimPlan().expansionPlan?.byOwner[request.expansionOwnerCheck] + : undefined; + if (!expansion) { + throw new ClaimKernelError('UNKNOWN_COVERAGE_REQUEST', `Unknown coverage request ${requestId}`); + } + return projectExpansionCoverage(claimProjection, instanceProjection, expansion, requestId); + } + + replayInstanceProjection(): InstanceProjection { + return replayInstanceEvents( + this.runtimeEvents.filter(event => + [ + 'CatalogReconciliationRequested', + 'SubgraphExpanded', + 'ControllerItemClaimPublished', + 'NodeGenerationInactivated', + 'NodeGenerationActivated', + 'SubgraphTombstoned', + 'ManagedRunAcquisitionFailed', + 'ManagedRunAcquired', + 'ManagedRunStarted', + 'ManagedRunCancelRequested', + 'ManagedRunTerminated', + ].includes(event.type) || + 'nodeGenerationId' in event || 'requestId' in event + ) as InstanceRuntimeEvent[] + ); + } + + startAttempt(input: { + sessionId: string; + checkId: string; + scope: ScopePath; + }): AttemptStartedEvent { + const plan = this.requireClaimPlan(); + if (!Object.prototype.hasOwnProperty.call(plan.effectiveDependenciesByCheck, input.checkId)) { + throw new ClaimKernelError('UNKNOWN_CHECK', `Unknown claim-mode check ${input.checkId}`); + } + const authoritativeInput = { + sessionId: input.sessionId, + checkId: input.checkId, + scope: input.scope.map(part => ({ ...part })), + }; + const ordinalKey = canonicalJson(authoritativeInput); + const ordinal = (this.attemptOrdinals.get(ordinalKey) || 0) + 1; + this.attemptOrdinals.set(ordinalKey, ordinal); + const fence = ++this.nextFence; + const attemptId = sha256Canonical({ ...authoritativeInput, ordinal }); + return this.appendRuntimeEvent({ + version: 1, + type: 'AttemptStarted', + eventId: this.nextRuntimeEventId(), + ...authoritativeInput, + attemptId, + fence, + }); + } + + scheduleCheck(input: { + sessionId: string; + checkId: string; + scope: ScopePath; + attemptId: string; + fence: number; + }): CheckScheduledEvent { + const plan = this.requireClaimPlan(); + const claimIds = exactActiveClaimIds(plan, this.claimProjection, input.checkId); + return this.appendRuntimeEvent({ + version: 1, + type: 'CheckScheduled', + eventId: this.nextRuntimeEventId(), + sessionId: input.sessionId, + checkId: input.checkId, + scope: input.scope.map(part => ({ ...part })), + attemptId: input.attemptId, + fence: input.fence, + claimIds: [...claimIds], + }); + } + + private reconcileCatalog(input: { + sessionId: string; + expansion: CompiledExpansion; + payload: unknown; + catalogClaimId: string; + startEventId: number; + projection: InstanceProjection; + parentSubgraphInstanceId: string | null; + expansionOwnerNodeInstanceId?: string; + }): { events: InstanceRuntimeEvent[]; projection: InstanceProjection } { + const expansion = input.expansion; + const nested = input.parentSubgraphInstanceId !== null; + const parent = nested + ? input.projection.instancesById[input.parentSubgraphInstanceId as string] + : undefined; + if ( + nested && + (!parent || + parent.status !== 'active' || + !input.expansionOwnerNodeInstanceId || + input.projection.nodesById[input.expansionOwnerNodeInstanceId]?.subgraphInstanceId !== + parent.subgraphInstanceId) + ) { + throw new ClaimKernelError( + 'INVALID_NESTED_EXPANSION_OWNER', + 'Nested reconciliation requires one exact active parent and owner node' + ); + } + if (nested) { + const catalog = input.projection.claimsById[input.catalogClaimId]; + const producer = catalog?.nodeGenerationId + ? input.projection.generationsById[catalog.nodeGenerationId] + : undefined; + if ( + !catalog?.active || + catalog.kind !== 'generated-output' || + catalog.claim !== expansion.catalogClaimRef || + catalog.subgraphInstanceId !== parent!.subgraphInstanceId || + !producer || + producer.nodeInstanceId !== input.expansionOwnerNodeInstanceId || + producer.nestedExpansionCatalogClaimRef !== expansion.catalogClaimRef || + producer.status !== 'running' || + !producer.scheduled || + input.projection.activeGenerationIdByNode[producer.nodeInstanceId] !== + producer.nodeGenerationId || + catalog.producerAttemptId !== producer.attemptId || + catalog.producerFence !== producer.fence + ) { + throw new ClaimKernelError( + 'INVALID_NESTED_CATALOG_LINEAGE', + 'Nested catalog must be the exact active output of its current fenced owner generation' + ); + } + } + expansion.catalogValidator(input.payload); + const rawItems = resolveJsonPointer(input.payload, expansion.itemsPointer); + if (!Array.isArray(rawItems)) { + throw new ClaimKernelError( + 'INVALID_CATALOG_ITEMS', + 'Catalog items pointer must resolve to an array' + ); + } + const items = new Map(); + for (const item of rawItems) { + expansion.itemValidator(item); + const key = canonicalCatalogKey(resolveJsonPointer(item, expansion.keyPointer)); + if (items.has(key)) { + throw new ClaimKernelError('DUPLICATE_CATALOG_KEY', `Duplicate catalog key ${key}`); + } + items.set(key, immutableCanonicalValue(item)); + } + + let projection = input.projection; + const events: InstanceRuntimeEvent[] = []; + let nextId = input.startEventId; + const stage = (event: StagedInstanceRuntimeEvent): void => { + const stored = immutableInstanceEvent({ ...event, eventId: nextId++ }); + projection = reduceInstanceEvent(projection, stored); + events.push(stored); + }; + + const allByKey = new Map( + Object.values(input.projection.instancesById) + .filter(instance => + instance.expansionOwnerCheck === expansion.expansionOwnerCheck && + (instance.parentSubgraphInstanceId || null) === input.parentSubgraphInstanceId && + (!nested || + instance.expansionOwnerNodeInstanceId === input.expansionOwnerNodeInstanceId) + ) + .map(instance => [instance.itemKey, instance] as const) + ); + const active = [...allByKey.values()].filter(instance => instance.status === 'active'); + const sortedItems = [...items.entries()].sort(([left], [right]) => left.localeCompare(right)); + + for (const [key] of sortedItems) { + if (!nested && allByKey.get(key)?.status === 'tombstoned') { + throw new ClaimKernelError( + 'TOMBSTONED_KEY_READD_UNSUPPORTED', + `Key ${key} was tombstoned` + ); + } + } + + const changed = sortedItems.filter(([key, item]) => { + const instance = allByKey.get(key); + if (!instance?.activeItemClaimId || instance.status !== 'active') return false; + return ( + input.projection.claimsById[instance.activeItemClaimId].payloadFingerprint !== + deriveItemFingerprint(item) || + (nested && instance.catalogClaimId !== input.catalogClaimId) + ); + }); + const revived = nested + ? sortedItems.filter(([key]) => allByKey.get(key)?.status === 'tombstoned') + : []; + const added = sortedItems.filter(([key]) => !allByKey.has(key)); + + const activateSources = ( + instanceId: string, + itemFingerprint: string + ): void => { + const instance = projection.instancesById[instanceId]; + for (const nodeKey of expansion.template.sourceNodeKeys) { + const node = expansion.template.nodesByKey[nodeKey]; + const nestedCatalogClaimRef = this.requireClaimPlan().expansionPlan!.byNestedOwner[ + qualifiedNestedExpansionOwner(expansion.template.name, nodeKey) + ]?.catalogClaimRef; + const inputIds: string[] = []; + let ready = true; + for (const consumption of node.consumptions) { + const matches = Object.values(projection.claimsById) + .filter(claim => + claim.active && + claim.subgraphInstanceId === instance.subgraphInstanceId && + claim.incarnation === instance.incarnation && + claim.claim === consumption.claim + ) + .sort((left, right) => left.claimId.localeCompare(right.claimId)); + if (matches.length !== 1) { + ready = false; + break; + } + inputIds.push(matches[0].claimId); + } + if (!ready) continue; + inputIds.sort(); + const nodeInstanceId = instance.nodeInstanceIdsByTemplateNode[nodeKey]; + const nodeGenerationId = deriveNodeGenerationId({ + nodeInstanceId, + incarnation: instance.incarnation, + itemFingerprint, + executionConfigDigest: node.executionConfigDigest, + activeInputClaimIds: inputIds, + }); + stage({ + version: 1, + type: 'NodeGenerationActivated', + sessionId: input.sessionId, + scope: instance.scope, + subgraphInstanceId: instance.subgraphInstanceId, + nodeInstanceId, + nodeGenerationId, + templateNodeKey: nodeKey, + checkId: nodeKey, + incarnation: instance.incarnation, + itemFingerprint, + executionConfigDigest: node.executionConfigDigest, + activeInputClaimIds: inputIds, + ...(nestedCatalogClaimRef + ? { nestedExpansionCatalogClaimRef: nestedCatalogClaimRef } + : {}), + }); + } + }; + + const publishItemAndActivateSources = ( + instanceId: string, + key: string, + item: unknown + ): void => { + let instance = projection.instancesById[instanceId]; + const payloadFingerprint = deriveItemFingerprint(item); + const incarnation = instance.incarnation + 1; + const claimId = deriveControllerItemClaimId({ + claim: expansion.itemClaimRef, + payloadFingerprint, + expansionSpecDigest: expansion.expansionSpecDigest, + catalogClaimId: input.catalogClaimId, + subgraphInstanceId: instance.subgraphInstanceId, + incarnation, + scope: instance.scope, + }); + stage({ + version: 1, + type: 'ControllerItemClaimPublished', + sessionId: input.sessionId, + scope: instance.scope, + expansionOwnerCheck: expansion.expansionOwnerCheck, + expansionSpecDigest: expansion.expansionSpecDigest, + catalogClaimId: input.catalogClaimId, + itemKey: key, + subgraphInstanceId: instance.subgraphInstanceId, + incarnation, + claimId, + claim: expansion.itemClaimRef, + payload: item, + payloadFingerprint, + parentClaimIds: [input.catalogClaimId], + }); + instance = projection.instancesById[instanceId]; + activateSources(instance.subgraphInstanceId, payloadFingerprint); + }; + + const tombstoneTree = (instanceId: string, sourceCatalogClaimId: string): void => { + const descendants = Object.values(projection.instancesById) + .filter(candidate => + candidate.status === 'active' && + candidate.parentSubgraphInstanceId === instanceId + ) + .sort((left, right) => left.itemKey.localeCompare(right.itemKey)); + for (const descendant of descendants) { + tombstoneTree(descendant.subgraphInstanceId, descendant.catalogClaimId); + } + const instance = projection.instancesById[instanceId]; + const generations = Object.values(projection.generationsById) + .filter(generation => + generation.subgraphInstanceId === instance.subgraphInstanceId && + generation.status !== 'inactive' + ) + .sort((left, right) => left.nodeGenerationId.localeCompare(right.nodeGenerationId)); + stage({ + version: 1, + type: 'SubgraphTombstoned', + sessionId: input.sessionId, + scope: instance.scope, + expansionOwnerCheck: instance.expansionOwnerCheck, + sourceCatalogClaimId, + itemKey: instance.itemKey, + subgraphInstanceId: instance.subgraphInstanceId, + lastIncarnation: instance.incarnation, + nodeGenerationIds: generations.map(value => value.nodeGenerationId).sort(), + outputClaimIds: generations.flatMap(value => value.completedOutputClaimIds).sort(), + }); + }; + const tombstoneDescendants = (instanceId: string): void => { + const descendants = Object.values(projection.instancesById) + .filter(candidate => + candidate.status === 'active' && + candidate.parentSubgraphInstanceId === instanceId + ) + .sort((left, right) => left.itemKey.localeCompare(right.itemKey)); + for (const descendant of descendants) { + tombstoneTree(descendant.subgraphInstanceId, descendant.catalogClaimId); + } + }; + + for (const instance of active + .filter(candidate => !items.has(candidate.itemKey)) + .sort((left, right) => left.itemKey.localeCompare(right.itemKey))) { + tombstoneTree(instance.subgraphInstanceId, input.catalogClaimId); + } + + for (const [key, item] of changed) { + let instance = projection.instancesById[allByKey.get(key)!.subgraphInstanceId]; + tombstoneDescendants(instance.subgraphInstanceId); + for (const nodeKey of expansion.template.reverseTopology) { + const nodeInstanceId = instance.nodeInstanceIdsByTemplateNode[nodeKey]; + const generationId = projection.activeGenerationIdByNode[nodeInstanceId]; + if (!generationId) continue; + const generation = projection.generationsById[generationId]; + stage({ + version: 1, + type: 'NodeGenerationInactivated', + sessionId: input.sessionId, + scope: instance.scope, + subgraphInstanceId: instance.subgraphInstanceId, + nodeInstanceId, + nodeGenerationId: generationId, + incarnation: generation.incarnation, + outputClaimIds: [...generation.completedOutputClaimIds].sort(), + reason: 'superseded', + }); + instance = projection.instancesById[instance.subgraphInstanceId]; + } + publishItemAndActivateSources(instance.subgraphInstanceId, key, item); + } + + for (const [key, item] of revived) { + const instance = projection.instancesById[allByKey.get(key)!.subgraphInstanceId]; + publishItemAndActivateSources(instance.subgraphInstanceId, key, item); + } + + for (const [key, item] of added) { + const subgraphInstanceId = nested + ? deriveSubgraphInstanceId({ + graphSemanticDigest: expansion.graphSemanticDigest, + parentSubgraphInstanceId: parent!.subgraphInstanceId, + expansionOwnerNodeInstanceId: input.expansionOwnerNodeInstanceId as string, + templateDigest: expansion.templateDigest, + itemKey: key, + }) + : deriveSubgraphInstanceId({ + graphSemanticDigest: expansion.graphSemanticDigest, + expansionOwnerCheck: expansion.expansionOwnerCheck, + parentSubgraphInstanceId: null, + templateDigest: expansion.templateDigest, + itemKey: key, + }); + const scope: KeyedScopePath = Object.freeze([ + ...(nested ? parent!.scope : []), + { + kind: 'keyed' as const, + expansionOwnerCheck: expansion.expansionOwnerCheck, + key, + subgraphInstanceId, + }, + ]) as KeyedScopePath; + stage({ + version: 1, + type: 'SubgraphExpanded', + sessionId: input.sessionId, + scope, + expansionOwnerCheck: expansion.expansionOwnerCheck, + graphSemanticDigest: expansion.graphSemanticDigest, + expansionSpecDigest: expansion.expansionSpecDigest, + templateDigest: expansion.templateDigest, + parentSubgraphInstanceId: input.parentSubgraphInstanceId, + ...(nested + ? { + expansionOwnerNodeInstanceId: input.expansionOwnerNodeInstanceId as string, + catalogClaimRef: expansion.catalogClaimRef, + } + : {}), + catalogClaimId: input.catalogClaimId, + itemKey: key, + subgraphInstanceId, + nodeInstanceIdsByTemplateNode: Object.fromEntries( + expansion.template.templateNodeKeys.map(nodeKey => [ + nodeKey, + deriveNodeInstanceId({ subgraphInstanceId, templateNodeKey: nodeKey }), + ]) + ), + }); + publishItemAndActivateSources(subgraphInstanceId, key, item); + } + + return { events, projection }; + } + + completeAttempt(input: { + sessionId: string; + checkId: string; + scope: ScopePath; + attemptId: string; + fence: number; + payload: unknown; + }): { + readonly claims: readonly CandidateClaimInput[]; + readonly completed: AttemptCompletedEvent; + } { + const plan = this.requireClaimPlan(); + const scheduled = this.claimProjection.scheduled.find( + event => + event.sessionId === input.sessionId && + event.checkId === input.checkId && + event.attemptId === input.attemptId && + event.fence === input.fence && + canonicalJson(event.scope) === canonicalJson(input.scope) + ); + if (!scheduled) { + throw new ClaimKernelError( + 'ATTEMPT_NOT_SCHEDULED', + `Attempt ${input.attemptId} was not scheduled before terminal processing` + ); + } + + let stagedProjection = this.claimProjection; + const stagedEvents: ClaimRuntimeEvent[] = []; + const claimIds: string[] = []; + for (const emission of plan.emissionsByCheck[input.checkId] || []) { + const built = buildClaimPublishedEvent({ + eventId: Math.max(stagedProjection.lastEventId, this.instanceProjection.lastEventId, ...stagedEvents.map(event => event.eventId)) + 1, + sessionId: input.sessionId, + checkId: input.checkId, + scope: input.scope, + attemptId: input.attemptId, + fence: input.fence, + claim: emission.claim, + payload: input.payload, + parentClaimIds: scheduled.claimIds, + projection: stagedProjection, + plan, + }); + const event = immutableRuntimeEvent(built); + stagedProjection = reduceClaimEvent(stagedProjection, event, plan); + stagedEvents.push(event); + claimIds.push(event.claimId); + } + + const rootExpansion = plan.expansionPlan?.byOwner[input.checkId]; + const catalogClaimId = claimIds.find(id => + stagedProjection.claims[id]?.claim === rootExpansion?.catalogClaimRef + ); + const reconciled = catalogClaimId && rootExpansion + ? this.reconcileCatalog({ + sessionId: input.sessionId, + expansion: rootExpansion, + payload: input.payload, + catalogClaimId, + startEventId: Math.max(stagedProjection.lastEventId, this.instanceProjection.lastEventId) + 1, + projection: this.instanceProjection, + parentSubgraphInstanceId: null, + }) + : { events: [] as InstanceRuntimeEvent[], projection: this.instanceProjection }; + const requestId = this.instanceProjection.attemptBindingsById[input.attemptId]; + if (requestId && !catalogClaimId) { + throw new ClaimKernelError('INVALID_REQUEST_CATALOG', + `Catalog request ${requestId} did not publish its configured catalog claim`); + } + + const completed = immutableRuntimeEvent({ + version: 1, + type: 'AttemptCompleted', + eventId: Math.max(stagedProjection.lastEventId, reconciled.projection.lastEventId) + 1, + sessionId: input.sessionId, + checkId: input.checkId, + scope: input.scope.map(part => ({ ...part })), + attemptId: input.attemptId, + fence: input.fence, + ...(requestId + ? { requestId, catalogClaimId: catalogClaimId as string } + : {}), + }); + stagedProjection = reduceClaimEvent(stagedProjection, completed, plan); + stagedEvents.push(completed); + + const finalInstanceProjection = requestId + ? reduceInstanceEvent(reconciled.projection, completed as any) + : reconciled.projection; + + this.runtimeEvents.push(...stagedEvents.slice(0, -1), ...reconciled.events, completed); + this.claimProjection = stagedProjection; + this.instanceProjection = finalInstanceProjection; + return Object.freeze({ + claims: Object.freeze(claimIds.map(claimId => stagedProjection.claims[claimId])), + completed, + }); + } + + failAttempt(input: { + sessionId: string; + checkId: string; + scope: ScopePath; + attemptId: string; + fence: number; + reason: string; + }): AttemptFailedEvent { + const requestId = this.instanceProjection.attemptBindingsById[input.attemptId]; + const event = immutableRuntimeEvent({ + sessionId: input.sessionId, + checkId: input.checkId, + attemptId: input.attemptId, + fence: input.fence, + reason: input.reason, + version: 1, + type: 'AttemptFailed', + eventId: this.nextRuntimeEventId(), + scope: input.scope.map(part => ({ ...part })), + ...(requestId ? { requestId } : {}), + }); + const claim = reduceClaimEvent(this.claimProjection, event, this.requireClaimPlan()); + const instance = requestId + ? reduceInstanceEvent(this.instanceProjection, event as any) + : this.instanceProjection; + this.runtimeEvents.push(event); this.claimProjection = claim; this.instanceProjection = instance; + return event; + } + + readRuntimeEvents(): readonly (ClaimRuntimeEvent | InstanceRuntimeEvent)[] { + return immutableCanonicalValue(this.runtimeEvents); + } + + getClaimProjection(): ClaimProjection { + return immutableCanonicalValue(this.claimProjection); + } + + replayClaimProjection(): ClaimProjection { + return replayClaimEvents( + this.readRuntimeEvents().filter(event => + ['AttemptStarted','ClaimPublished','CheckScheduled','AttemptCompleted','AttemptFailed'].includes(event.type) && + !('nodeGenerationId' in event) + ) as ClaimRuntimeEvent[], + this.requireClaimPlan() + ); + } + + isCheckReady(checkId: string): boolean { + try { + exactActiveClaimIds(this.requireClaimPlan(), this.claimProjection, checkId); + return true; + } catch (error) { + if (error instanceof ClaimKernelError && error.code === 'CLAIM_NOT_READY') return false; + throw error; + } + } + + readCheckClaims(checkId: string): Readonly> { + const plan = this.requireClaimPlan(); + const claimIds = exactActiveClaimIds(plan, this.claimProjection, checkId); + const selected: Record = {}; + for (const [index, consumption] of (plan.consumptionsByCheck[checkId] || []).entries()) { + const claimId = claimIds[index]; + const claim = this.claimProjection.claims[claimId]; + if (claim) selected[consumption.claim] = claim; + } + return Object.freeze(selected); + } + // Lightweight helpers for debugging/metrics size(): number { return this.entries.length; @@ -115,15 +1895,14 @@ export class ContextView { } private sameScope(a: ScopePath, b: ScopePath): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i].check !== b[i].check || a[i].index !== b[i].index) return false; - } - return true; + return canonicalJson(a) === canonicalJson(b); } // distance from ancestor to current; -1 if not ancestor private ancestorDistance(ancestor: ScopePath, current: ScopePath): number { + if ([...ancestor, ...current].some(segment => (segment as any).kind === 'keyed')) { + return this.sameScope(ancestor, current) ? 0 : -1; + } if (ancestor.length > current.length) return -1; // Treat root scope ([]) as non-ancestor for unrelated branches if (ancestor.length === 0 && current.length > 0) return -1; diff --git a/src/state-machine-execution-engine.ts b/src/state-machine-execution-engine.ts index ee5eb6445..fdfa907d6 100644 --- a/src/state-machine-execution-engine.ts +++ b/src/state-machine-execution-engine.ts @@ -3,13 +3,41 @@ import { AnalysisResult } from './output-formatters'; import type { VisorConfig } from './types/config'; import type { PRInfo } from './pr-analyzer'; import { StateMachineRunner } from './state-machine/runner'; -import type { EngineContext } from './types/engine'; +import type { EngineContext, RunState } from './types/engine'; import { ExecutionJournal } from './snapshot-store'; +import type { GraphJournalCheckpointV1 } from './snapshot-store'; +import type { InstanceProjection } from './state-machine/graph/instance-kernel'; import { logger } from './logger'; import type { DebugVisualizerServer } from './debug-visualizer/ws-server'; import { SandboxManager } from './sandbox/sandbox-manager'; import * as path from 'path'; import * as fs from 'fs'; +import type { + BuiltGraphCheckpointContext, + GraphCheckpointBootstrap, +} from './state-machine/context/build-engine-context'; + +export interface GraphCheckpointContinuationInput { + checkpoint: unknown; + expansionOwnerCheck: string; + config: VisorConfig; + prInfo: PRInfo; + debug?: boolean; + maxParallelism?: number; + failFast?: boolean; +} + +export interface GraphCheckpointContinuationResult { + requestId: string; + result: ExecutionResult; + checkpoint: GraphJournalCheckpointV1; +} + +interface PreparedEngineRun { + readonly context: EngineContext; + readonly result: ExecutionResult; + readonly requestId?: string; +} /** * State machine-based execution engine @@ -253,6 +281,34 @@ export class StateMachineExecutionEngine { tagFilter?: import('./types/config').TagFilter, _pauseGate?: () => Promise ): Promise { + const prepared = await this.executeGroupedChecksInternal( + prInfo, + checks, + timeout, + config, + outputFormat, + debug, + maxParallelism, + failFast, + tagFilter, + _pauseGate + ); + return prepared.result; + } + + private async executeGroupedChecksInternal( + prInfo: PRInfo, + checks: string[], + timeout?: number, + config?: VisorConfig, + outputFormat?: string, + debug?: boolean, + maxParallelism?: number, + failFast?: boolean, + tagFilter?: import('./types/config').TagFilter, + _pauseGate?: () => Promise, + graphCheckpointBootstrap?: GraphCheckpointBootstrap + ): Promise { if (debug) { logger.info('[StateMachine] Using state machine engine'); } @@ -273,6 +329,25 @@ export class StateMachineExecutionEngine { } : config; + // Build/restore the engine context before registering tools or initializing + // any other service. Continuation restore and owner validation therefore + // remain fail-fast and side-effect free. + const builtContext = this.buildEngineContext( + configWithTagFilter, + prInfo, + debug, + maxParallelism, + failFast, + checks, // Pass the explicit checks list + graphCheckpointBootstrap + ); + const context = graphCheckpointBootstrap + ? (builtContext as BuiltGraphCheckpointContext).context + : (builtContext as EngineContext); + const requestId = graphCheckpointBootstrap + ? (builtContext as BuiltGraphCheckpointContext).requestId + : undefined; + // Register global custom tools once per run so MCP custom transport can resolve them. try { const { CheckProviderRegistry } = await import('./providers/check-provider-registry'); @@ -284,15 +359,10 @@ export class StateMachineExecutionEngine { ); } - // Build engine context - const context = this.buildEngineContext( - configWithTagFilter, - prInfo, - debug, - maxParallelism, - failFast, - checks // Pass the explicit checks list - ); + // Continuation skips Init, so initialize its fresh memory service here. + if (graphCheckpointBootstrap) { + await context.memory.initialize(); + } // Create SandboxManager if sandboxes are configured if (configWithTagFilter.sandboxes && Object.keys(configWithTagFilter.sandboxes).length > 0) { @@ -382,8 +452,11 @@ export class StateMachineExecutionEngine { // Copy execution context (hooks, etc.) from legacy engine context.executionContext = this.getExecutionContext(); - // Store context for later access (e.g., getOutputHistorySnapshot) - this._lastContext = context; + // Preserve the normal-run visibility point for frontend callbacks. A + // continuation keeps this unset until all setup has passed its gates. + if (!graphCheckpointBootstrap) { + this._lastContext = context; + } // Optionally enable event-driven frontends if configured let frontendsHost: any | undefined; @@ -553,9 +626,36 @@ export class StateMachineExecutionEngine { // Create and run state machine with debug server support (M4) const runner = new StateMachineRunner(context, this.debugServer); - this._lastRunner = runner; + if (graphCheckpointBootstrap) { + const fresh = runner.getState(); + const continuationState: RunState = { + currentState: 'LevelDispatch', + wave: 1, + levelQueue: [], + eventQueue: [], + activeDispatches: new Map(), + completedChecks: new Set(), + flags: { + failFastTriggered: false, + forwardRunRequested: false, + maxWorkflowDepth: fresh.flags.maxWorkflowDepth, + currentWorkflowDepth: 0, + }, + stats: new Map(), + historyLog: [], + forwardRunGuards: new Set(), + currentLevelChecks: new Set(), + routingLoopCount: 0, + pendingRunScopes: new Map(), + }; + runner.setState(continuationState); + } try { + // Keep these references untouched through all validation/setup gates; + // install the new run only at the point where execution starts. + this._lastContext = context; + this._lastRunner = runner; const result = await runner.run(); // Stop frontends if started @@ -598,7 +698,7 @@ export class StateMachineExecutionEngine { } } - return result; + return { context, result, requestId }; } finally { // Cleanup sandbox containers if (context.sandboxManager) { @@ -619,8 +719,9 @@ export class StateMachineExecutionEngine { debug?: boolean, maxParallelism?: number, failFast?: boolean, - requestedChecks?: string[] - ): EngineContext { + requestedChecks?: string[], + graphCheckpointBootstrap?: GraphCheckpointBootstrap + ): EngineContext | BuiltGraphCheckpointContext { const { buildEngineContextForRun } = require('./state-machine/context/build-engine-context'); return buildEngineContextForRun( this.workingDirectory, @@ -629,10 +730,111 @@ export class StateMachineExecutionEngine { debug, maxParallelism, failFast, - requestedChecks + requestedChecks, + graphCheckpointBootstrap ); } + /** + * Reconcile one compiled catalog owner from a quiescent Graph-v2 checkpoint. + * This operation intentionally has no legacy snapshot or runner-state path. + */ + public async continueGraphCheckpoint( + input: GraphCheckpointContinuationInput + ): Promise { + const prepared = await this.executeGroupedChecksInternal( + input.prInfo, + [], + undefined, + input.config, + undefined, + input.debug, + input.maxParallelism, + input.failFast, + undefined, + undefined, + { + checkpoint: input.checkpoint, + expansionOwnerCheck: input.expansionOwnerCheck, + } + ); + + const checkpoint = prepared.context.journal.exportGraphCheckpoint(prepared.context.sessionId); + return { + requestId: prepared.requestId!, + result: prepared.result, + checkpoint, + }; + } + + /** Queue a compiled catalog owner on the currently active runner/journal. */ + public requestCatalogReconciliation(ownerCheck: string) { + const runner = this._lastRunner; + if (!runner) { + const error = new Error('Catalog reconciliation requires an active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + } + return runner.requestCatalogReconciliation(ownerCheck); + } + + public getInstanceProjection(): InstanceProjection { + const journal = this._lastContext?.journal; + if (!journal) { + const error = new Error('Instance projection requires a prior or active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + } + return journal.getInstanceProjection(); + } + + public replayInstanceProjection(): InstanceProjection { + const journal = this._lastContext?.journal; + if (!journal) { + const error = new Error('Instance projection requires a prior or active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + } + return journal.replayInstanceProjection(); + } + + /** Read the deterministic request-bound expansion coverage projection. */ + public getExpansionCoverageProjection(requestId: string) { + const journal = (this as any)._lastContext?.journal as ExecutionJournal | undefined; + if (!journal) { + const error = new Error('Expansion coverage requires a prior or active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + } + return journal.getExpansionCoverageProjection(requestId); + } + + public getExpansionCoverageRequestIds(ownerCheck?: string) { + const journal = (this as any)._lastContext?.journal as ExecutionJournal | undefined; + if (!journal) return Object.freeze([] as string[]); + return journal.getExpansionCoverageRequestIds(ownerCheck); + } + + public replayExpansionCoverageProjection(requestId: string) { + const journal = (this as any)._lastContext?.journal as ExecutionJournal | undefined; + if (!journal) { + const error = new Error('Expansion coverage requires a prior or active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + } + return journal.replayExpansionCoverageProjection(requestId); + } + /** * Get output history snapshot for test framework compatibility * Extracts output history from the journal diff --git a/src/state-machine/context/build-engine-context.ts b/src/state-machine/context/build-engine-context.ts index 2a05f8575..59d56e4d9 100644 --- a/src/state-machine/context/build-engine-context.ts +++ b/src/state-machine/context/build-engine-context.ts @@ -2,12 +2,25 @@ import type { VisorConfig, EventTrigger } from '../../types/config'; import type { PRInfo } from '../../pr-analyzer'; import type { EngineContext, CheckMetadata } from '../../types/engine'; import { ExecutionJournal } from '../../snapshot-store'; +import type { GraphJournalCheckpointV1 } from '../../snapshot-store'; import { MemoryStore } from '../../memory-store'; import { generateHumanId } from '../../utils/human-id'; import { logger } from '../../logger'; import type { VisorConfig as VCfg, CheckConfig as CfgCheck } from '../../types/config'; import { WorkspaceManager } from '../../utils/workspace-manager'; import { FairConcurrencyLimiter } from '../../utils/fair-concurrency-limiter'; +import { compileClaimPlan } from '../graph/claim-plan'; + +/** Private bootstrap used only by the engine's one-shot Graph checkpoint continuation. */ +export interface GraphCheckpointBootstrap { + readonly checkpoint: unknown; + readonly expansionOwnerCheck: string; +} + +export interface BuiltGraphCheckpointContext { + readonly context: EngineContext; + readonly requestId: string; +} /** * Apply minimal criticality defaults in-place. @@ -39,10 +52,67 @@ export function buildEngineContextForRun( maxParallelism?: number, failFast?: boolean, requestedChecks?: string[] -): EngineContext { +): EngineContext; +export function buildEngineContextForRun( + workingDirectory: string, + config: VisorConfig, + prInfo: PRInfo, + debug?: boolean, + maxParallelism?: number, + failFast?: boolean, + requestedChecks?: string[], + graphCheckpointBootstrap?: GraphCheckpointBootstrap +): BuiltGraphCheckpointContext; +export function buildEngineContextForRun( + workingDirectory: string, + config: VisorConfig, + prInfo: PRInfo, + debug?: boolean, + maxParallelism?: number, + failFast?: boolean, + requestedChecks?: string[], + graphCheckpointBootstrap?: GraphCheckpointBootstrap +): EngineContext | BuiltGraphCheckpointContext { // Deep clone provided config to avoid cross-run mutations between tests/runs const clonedConfig: VisorConfig = JSON.parse(JSON.stringify(config)); + // Compile exact claim bindings once. Materialize effective dependencies only + // into the per-run clone; the caller's authored configuration remains untouched. + const claimPlan = compileClaimPlan(clonedConfig); + if (claimPlan.active) { + const clonedChecks = clonedConfig.checks || clonedConfig.steps || {}; + for (const [checkId, dependencies] of Object.entries( + claimPlan.effectiveDependenciesByCheck + )) { + const check = clonedChecks[checkId]; + if (check) check.depends_on = [...dependencies]; + } + clonedConfig.checks = clonedChecks; + } + + // Restore the graph prefix before creating any session-capturing service. The + // restore routine owns all envelope, integrity, graph, replay, quiescence, + // and allocator validation; the engine only reads the validated envelope + // session after it succeeds. + let journal: ExecutionJournal; + let sessionId: string; + let requestId: string | undefined; + if (graphCheckpointBootstrap) { + journal = ExecutionJournal.restoreGraphCheckpoint( + claimPlan, + graphCheckpointBootstrap.checkpoint + ); + const validatedCheckpoint = graphCheckpointBootstrap.checkpoint as GraphJournalCheckpointV1; + sessionId = validatedCheckpoint.sessionId; + requestId = journal.requestCatalogReconciliation({ + sessionId, + ownerCheck: graphCheckpointBootstrap.expansionOwnerCheck, + }).requestId; + } else { + sessionId = generateHumanId(); + journal = new ExecutionJournal(claimPlan); + } + // Build check metadata const checks: Record = {}; @@ -96,15 +166,14 @@ export function buildEngineContextForRun( } } - // Initialize journal and memory - const journal = new ExecutionJournal(); + // Initialize memory only after checkpoint restore and the direct owner + // request above. The continuation skips Init but receives this fresh store. const memory = MemoryStore.getInstance(clonedConfig.memory); // Create shared AI concurrency limiter if configured. // Uses a global singleton fair limiter: round-robin across sessions so // no single user/task can starve others. let sharedConcurrencyLimiter: any = undefined; - const sessionId = generateHumanId(); if (clonedConfig.max_ai_concurrency) { const fairLimiter = FairConcurrencyLimiter.getInstance(clonedConfig.max_ai_concurrency); // Bind this engine run's session ID into acquire/release so the fair limiter @@ -141,10 +210,11 @@ export function buildEngineContextForRun( }; } - return { + const context: EngineContext = { mode: 'state-machine', config: clonedConfig, checks, + claimPlan, journal, memory, workingDirectory, @@ -159,6 +229,13 @@ export function buildEngineContextForRun( // Store prInfo for later access (e.g., in getOutputHistorySnapshot) prInfo, }; + + if (graphCheckpointBootstrap) { + // requestCatalogReconciliation always returns a request for a valid owner; + // retain that exact ID without inserting a second request later. + return { context, requestId: requestId! }; + } + return context; } /** diff --git a/src/state-machine/dispatch/managed-run.ts b/src/state-machine/dispatch/managed-run.ts new file mode 100644 index 000000000..c337ea0eb --- /dev/null +++ b/src/state-machine/dispatch/managed-run.ts @@ -0,0 +1,667 @@ +import type { + ManagedAgentRun, + ManagedRunCancelReceiptV1, + ManagedRunCleanupReceiptV1, + ManagedRunOutcomeV1, + ManagedRunStartRequest, + ManagedRunStartedReceiptV1, +} from '../../providers/check-provider.interface'; +import type { ReviewSummary } from '../../reviewer'; +import { canonicalJson, immutableCanonicalValue } from '../graph/claim-kernel'; +import { + requireKeyedScopePath, + type ManagedRunBindingV1, + type ManagedRunFailureCode, +} from '../graph/instance-kernel'; + +type ManagedRunProtocolFailureCode = Extract< + ManagedRunFailureCode, + | 'MANAGED_HANDLE_INVALID' + | 'MANAGED_BINDING_MISMATCH' + | 'MANAGED_START_FAILED' + | 'MANAGED_STARTED_RECEIPT_INVALID' + | 'MANAGED_OUTCOME_RECEIPT_INVALID' + | 'MANAGED_CANCEL_FAILED' + | 'MANAGED_CANCEL_RECEIPT_INVALID' + | 'MANAGED_CLOSE_FAILED' + | 'MANAGED_CLEANUP_RECEIPT_INVALID' +>; + +const PROTOCOL_MESSAGES: Readonly> = Object.freeze({ + MANAGED_HANDLE_INVALID: 'Managed provider returned an invalid handle', + MANAGED_BINDING_MISMATCH: 'Managed provider binding does not match controller authority', + MANAGED_START_FAILED: 'Managed provider acquisition failed', + MANAGED_STARTED_RECEIPT_INVALID: 'Managed provider returned an invalid started receipt', + MANAGED_OUTCOME_RECEIPT_INVALID: 'Managed provider returned an invalid outcome receipt', + MANAGED_CANCEL_FAILED: 'Managed provider cancellation failed', + MANAGED_CANCEL_RECEIPT_INVALID: 'Managed provider returned an invalid cancellation receipt', + MANAGED_CLOSE_FAILED: 'Managed provider cleanup failed', + MANAGED_CLEANUP_RECEIPT_INVALID: 'Managed provider returned an invalid cleanup receipt', +}); + +const BINDING_KEYS = Object.freeze([ + 'managedRunId', + 'sessionId', + 'checkId', + 'scope', + 'nodeInstanceId', + 'nodeGenerationId', + 'attemptId', + 'fence', +] as const); + +const HANDLE_KEYS = Object.freeze(['binding', 'started', 'outcome', 'cancel', 'close'] as const); + +// Capture controller intrinsics before any provider code can run. Provider-owned +// Promise methods and later prototype changes are never consulted. +const ControllerPromise = Promise; +const controllerPromiseThen = Promise.prototype.then; + +/** Stable, data-minimal failure surfaced by the managed provider boundary. */ +export class ManagedRunProtocolError extends Error { + readonly code: ManagedRunProtocolFailureCode; + + constructor(code: ManagedRunProtocolFailureCode) { + super(PROTOCOL_MESSAGES[code]); + this.name = 'ManagedRunProtocolError'; + this.code = code; + } +} + +export interface ManagedRunSnapshot { + readonly binding: ManagedRunBindingV1; + readonly started: Promise; + readonly outcome: Promise; + readonly cancelOnce: ( + reason: 'deadline', + fence: number + ) => Promise; + readonly closeOnce: () => Promise; +} + +export interface ManagedRunDeadlineSettlement { + readonly cancel: PromiseSettledResult | null; + readonly close: PromiseSettledResult; + readonly cancelRequested: boolean; +} + +export interface ManagedRunDeadline { + /** Resolves only after the independently started cancel and close calls both settle. */ + readonly fired: Promise; + readonly didFire: () => boolean; + /** The controller calls this only after close has settled on the ordinary path. */ + readonly clear: () => void; +} + +function protocolError(code: ManagedRunProtocolFailureCode): ManagedRunProtocolError { + return new ManagedRunProtocolError(code); +} + +function isObject(value: unknown): value is object { + return value !== null && typeof value === 'object'; +} + +function isCallable(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function'; +} + +function hasExactOwnKeys(value: object, expected: readonly string[]): boolean { + const actual = Reflect.ownKeys(value); + if (actual.length !== expected.length || actual.some(key => typeof key !== 'string')) { + return false; + } + const wanted = new Set(expected); + return actual.every(key => wanted.has(key as string)); +} + +function isPlainRecord(value: unknown): value is object { + if (!isObject(value) || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function requireNonEmptyString(value: unknown, code: ManagedRunProtocolFailureCode): string { + if (typeof value !== 'string' || value.length === 0) throw protocolError(code); + return value; +} + +function normalizeBinding( + value: unknown, + code: ManagedRunProtocolFailureCode +): ManagedRunBindingV1 { + try { + if (!isPlainRecord(value) || !hasExactOwnKeys(value, BINDING_KEYS)) { + throw protocolError(code); + } + + const managedRunId = Reflect.get(value, 'managedRunId', value) as unknown; + const sessionId = Reflect.get(value, 'sessionId', value) as unknown; + const checkId = Reflect.get(value, 'checkId', value) as unknown; + const scopeValue = Reflect.get(value, 'scope', value) as unknown; + const nodeInstanceId = Reflect.get(value, 'nodeInstanceId', value) as unknown; + const nodeGenerationId = Reflect.get(value, 'nodeGenerationId', value) as unknown; + const attemptId = Reflect.get(value, 'attemptId', value) as unknown; + const fence = Reflect.get(value, 'fence', value) as unknown; + const scope = requireKeyedScopePath(scopeValue); + + if (typeof fence !== 'number' || !Number.isInteger(fence)) { + throw protocolError(code); + } + + return immutableCanonicalValue({ + managedRunId: requireNonEmptyString(managedRunId, code), + sessionId: requireNonEmptyString(sessionId, code), + checkId: requireNonEmptyString(checkId, code), + scope, + nodeInstanceId: requireNonEmptyString(nodeInstanceId, code), + nodeGenerationId: requireNonEmptyString(nodeGenerationId, code), + attemptId: requireNonEmptyString(attemptId, code), + fence, + }); + } catch { + throw protocolError(code); + } +} + +function bindingEquals(left: ManagedRunBindingV1, right: ManagedRunBindingV1): boolean { + return ( + left.managedRunId === right.managedRunId && + left.sessionId === right.sessionId && + left.checkId === right.checkId && + canonicalJson(left.scope) === canonicalJson(right.scope) && + left.nodeInstanceId === right.nodeInstanceId && + left.nodeGenerationId === right.nodeGenerationId && + left.attemptId === right.attemptId && + left.fence === right.fence + ); +} + +function controllerRejected(reason: unknown): Promise { + return new ControllerPromise((_resolve, reject) => reject(reason)); +} + +function mirrorNativePromise( + value: unknown, + code: ManagedRunProtocolFailureCode +): Promise { + let resolveMirror!: (value: T | PromiseLike) => void; + let rejectMirror!: (reason?: unknown) => void; + const mirror = new ControllerPromise((resolve, reject) => { + resolveMirror = resolve; + rejectMirror = reject; + }); + try { + Reflect.apply(controllerPromiseThen, value, [resolveMirror, rejectMirror]); + } catch { + throw protocolError(code); + } + return mirror; +} + +function observeRejection(promise: Promise): void { + void Reflect.apply(controllerPromiseThen, promise, [undefined, () => undefined]); +} + +function copyAndFreezePlainData(value: T, copies = new WeakMap()): T { + if (!isObject(value) || typeof value === 'function') return value; + if (!Array.isArray(value) && !isPlainRecord(value)) return value; + + const existing = copies.get(value); + if (existing !== undefined) return existing as T; + + const copy: unknown[] | Record = Array.isArray(value) + ? [] + : Object.create(Object.getPrototypeOf(value)); + copies.set(value, copy); + + for (const key of Reflect.ownKeys(value)) { + if (Array.isArray(value) && key === 'length') continue; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable) continue; + Object.defineProperty(copy, key, { + configurable: true, + enumerable: true, + writable: true, + value: copyAndFreezePlainData(Reflect.get(value, key, value), copies), + }); + } + return Object.freeze(copy) as T; +} + +function snapshotReadonlyMap( + source: ReadonlyMap, + copies: WeakMap +): ReadonlyMap { + const entries = Array.from(source, ([key, value]) => + Object.freeze([key, copyAndFreezePlainData(value, copies)] as const) + ); + const snapshot = new Map(entries); + let view!: ReadonlyMap; + const shell = Object.create(null) as Record; + Object.defineProperties(shell, { + size: { enumerable: true, get: () => snapshot.size }, + get: { enumerable: true, value: (key: string) => snapshot.get(key) }, + has: { enumerable: true, value: (key: string) => snapshot.has(key) }, + entries: { enumerable: true, value: () => snapshot.entries() }, + keys: { enumerable: true, value: () => snapshot.keys() }, + values: { enumerable: true, value: () => snapshot.values() }, + forEach: { + enumerable: true, + value: ( + callback: (value: ReviewSummary, key: string, map: ReadonlyMap) => void, + thisArg?: unknown + ) => snapshot.forEach((value, key) => callback.call(thisArg, value, key, view)), + }, + [Symbol.iterator]: { enumerable: false, value: () => snapshot.entries() }, + }); + view = Object.freeze(shell) as unknown as ReadonlyMap; + return view; +} + +/** Build an immutable provider-only view without freezing controller-owned inputs. */ +export function snapshotManagedRunStartRequest( + request: ManagedRunStartRequest +): ManagedRunStartRequest { + const copies = new WeakMap(); + const snapshot = { + prInfo: copyAndFreezePlainData(request.prInfo, copies), + checkConfig: copyAndFreezePlainData(request.checkConfig, copies), + dependencyResults: snapshotReadonlyMap(request.dependencyResults, copies), + executionContext: copyAndFreezePlainData(request.executionContext, copies), + binding: copyAndFreezePlainData(request.binding, copies), + }; + return Object.freeze(snapshot); +} + +/** Preserve the selected timeout value when valid; otherwise arm an immediate deadline. */ +export function normalizeManagedRunTimeout(timeoutMs: number): number { + return Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs : 0; +} + +interface CapturedHandleMembers { + readonly receiver: object; + readonly binding: unknown; + readonly started: unknown; + readonly outcome: unknown; + readonly cancel: (...args: unknown[]) => unknown; + readonly close: (...args: unknown[]) => unknown; +} + +function readHandleMembers(value: unknown): CapturedHandleMembers { + try { + if (!isPlainRecord(value)) throw protocolError('MANAGED_HANDLE_INVALID'); + const thenMember = Reflect.get(value, 'then', value) as unknown; + if (thenMember !== undefined || !hasExactOwnKeys(value, HANDLE_KEYS)) { + throw protocolError('MANAGED_HANDLE_INVALID'); + } + + // Every authoritative member is read exactly once inside this guarded block. + const binding = Reflect.get(value, 'binding', value) as unknown; + const started = Reflect.get(value, 'started', value) as unknown; + const outcome = Reflect.get(value, 'outcome', value) as unknown; + const cancel = Reflect.get(value, 'cancel', value) as unknown; + const close = Reflect.get(value, 'close', value) as unknown; + + if (!isCallable(cancel) || !isCallable(close)) { + throw protocolError('MANAGED_HANDLE_INVALID'); + } + return { receiver: value, binding, started, outcome, cancel, close }; + } catch { + throw protocolError('MANAGED_HANDLE_INVALID'); + } +} + +/** + * Invoke start exactly once, then synchronously detach all handle authority + * from provider mutation before any provider-controlled await can occur. + */ +export function snapshotManagedRun( + start: () => ManagedAgentRun, + expectedBinding: ManagedRunBindingV1 +): ManagedRunSnapshot { + const binding = normalizeBinding(expectedBinding, 'MANAGED_BINDING_MISMATCH'); + let returned: unknown; + try { + returned = start(); + } catch { + throw protocolError('MANAGED_START_FAILED'); + } + + const members = readHandleMembers(returned); + const echoedBinding = normalizeBinding(members.binding, 'MANAGED_HANDLE_INVALID'); + if (!bindingEquals(binding, echoedBinding)) { + throw protocolError('MANAGED_BINDING_MISMATCH'); + } + + const started = mirrorNativePromise( + members.started, + 'MANAGED_HANDLE_INVALID' + ); + const outcome = mirrorNativePromise( + members.outcome, + 'MANAGED_HANDLE_INVALID' + ); + observeRejection(started); + observeRejection(outcome); + + let cancelStarted = false; + let cancelPromise: Promise | undefined; + const cancelOnce = ( + reason: 'deadline', + fence: number + ): Promise => { + if (!cancelStarted) { + cancelStarted = true; + let firstPromise: Promise; + try { + const value: unknown = Reflect.apply(members.cancel, members.receiver, [reason, fence]); + firstPromise = mirrorNativePromise( + value, + 'MANAGED_CANCEL_FAILED' + ); + } catch { + firstPromise = controllerRejected(protocolError('MANAGED_CANCEL_FAILED')); + } + cancelPromise = firstPromise; + observeRejection(firstPromise); + } + return cancelPromise as Promise; + }; + + let closeStarted = false; + let closePromise: Promise | undefined; + const closeOnce = (): Promise => { + if (!closeStarted) { + closeStarted = true; + let firstPromise: Promise; + try { + const value: unknown = Reflect.apply(members.close, members.receiver, []); + firstPromise = mirrorNativePromise( + value, + 'MANAGED_CLOSE_FAILED' + ); + } catch { + firstPromise = controllerRejected(protocolError('MANAGED_CLOSE_FAILED')); + } + closePromise = firstPromise; + observeRejection(firstPromise); + } + return closePromise as Promise; + }; + + return Object.freeze({ + binding, + started, + outcome, + cancelOnce, + closeOnce, + }); +} + +function normalizeReceiptBinding( + value: unknown, + expectedBinding: ManagedRunBindingV1, + code: ManagedRunProtocolFailureCode +): ManagedRunBindingV1 { + const expected = normalizeBinding(expectedBinding, code); + const actual = normalizeBinding(value, code); + if (!bindingEquals(expected, actual)) throw protocolError(code); + return expected; +} + +export function normalizeManagedRunStartedReceipt( + value: unknown, + expectedBinding: ManagedRunBindingV1 +): ManagedRunStartedReceiptV1 { + const code = 'MANAGED_STARTED_RECEIPT_INVALID'; + try { + if (!isPlainRecord(value) || !hasExactOwnKeys(value, ['version', 'kind', 'binding'])) { + throw protocolError(code); + } + const version = Reflect.get(value, 'version', value) as unknown; + const kind = Reflect.get(value, 'kind', value) as unknown; + const receiptBinding = Reflect.get(value, 'binding', value) as unknown; + if (version !== 1 || kind !== 'started') throw protocolError(code); + const binding = normalizeReceiptBinding(receiptBinding, expectedBinding, code); + return Object.freeze({ version: 1, kind: 'started', binding }); + } catch { + throw protocolError(code); + } +} + +export function normalizeManagedRunOutcome( + value: unknown, + expectedBinding: ManagedRunBindingV1 +): ManagedRunOutcomeV1 { + const code = 'MANAGED_OUTCOME_RECEIPT_INVALID'; + try { + if (!isPlainRecord(value)) throw protocolError(code); + const kind = Reflect.get(value, 'kind', value) as unknown; + const expectedKeys = kind === 'succeeded' + ? ['version', 'kind', 'binding', 'summary'] + : ['version', 'kind', 'binding']; + if (!hasExactOwnKeys(value, expectedKeys)) throw protocolError(code); + + const version = Reflect.get(value, 'version', value) as unknown; + const receiptBinding = Reflect.get(value, 'binding', value) as unknown; + if (version !== 1 || (kind !== 'succeeded' && kind !== 'failed')) { + throw protocolError(code); + } + const binding = normalizeReceiptBinding(receiptBinding, expectedBinding, code); + if (kind === 'failed') return Object.freeze({ version: 1, kind, binding }); + + const summary = Reflect.get(value, 'summary', value) as unknown; + if (!isPlainRecord(summary)) throw protocolError(code); + return Object.freeze({ + version: 1, + kind, + binding, + // Detach semantic evidence from the provider before cleanup can await. + // Later mutation of the outcome receipt cannot change Visor's decision. + summary: immutableCanonicalValue(summary as ReviewSummary), + }); + } catch { + throw protocolError(code); + } +} + +export function normalizeManagedRunCancelReceipt( + value: unknown, + expectedBinding: ManagedRunBindingV1 +): ManagedRunCancelReceiptV1 { + const code = 'MANAGED_CANCEL_RECEIPT_INVALID'; + try { + if ( + !isPlainRecord(value) || + !hasExactOwnKeys(value, ['version', 'kind', 'binding', 'reason']) + ) { + throw protocolError(code); + } + const version = Reflect.get(value, 'version', value) as unknown; + const kind = Reflect.get(value, 'kind', value) as unknown; + const receiptBinding = Reflect.get(value, 'binding', value) as unknown; + const reason = Reflect.get(value, 'reason', value) as unknown; + if (version !== 1 || kind !== 'cancelled' || reason !== 'deadline') { + throw protocolError(code); + } + const binding = normalizeReceiptBinding(receiptBinding, expectedBinding, code); + return Object.freeze({ version: 1, kind: 'cancelled', binding, reason: 'deadline' }); + } catch { + throw protocolError(code); + } +} + +export function normalizeManagedRunCleanupReceipt( + value: unknown, + expectedBinding: ManagedRunBindingV1 +): ManagedRunCleanupReceiptV1 { + const code = 'MANAGED_CLEANUP_RECEIPT_INVALID'; + try { + if ( + !isPlainRecord(value) || + !hasExactOwnKeys(value, [ + 'version', + 'kind', + 'binding', + 'status', + 'activeChildren', + 'activeResources', + ]) + ) { + throw protocolError(code); + } + const version = Reflect.get(value, 'version', value) as unknown; + const kind = Reflect.get(value, 'kind', value) as unknown; + const receiptBinding = Reflect.get(value, 'binding', value) as unknown; + const status = Reflect.get(value, 'status', value) as unknown; + const activeChildren = Reflect.get(value, 'activeChildren', value) as unknown; + const activeResources = Reflect.get(value, 'activeResources', value) as unknown; + if ( + version !== 1 || + kind !== 'cleanup' || + status !== 'clean' || + activeChildren !== 0 || + activeResources !== 0 + ) { + throw protocolError(code); + } + const binding = normalizeReceiptBinding(receiptBinding, expectedBinding, code); + return Object.freeze({ + version: 1, + kind: 'cleanup', + binding, + status: 'clean', + activeChildren: 0, + activeResources: 0, + }); + } catch { + throw protocolError(code); + } +} + +function frozenSettledResult(result: PromiseSettledResult): PromiseSettledResult { + return result.status === 'fulfilled' + ? Object.freeze({ status: 'fulfilled', value: result.value }) + : Object.freeze({ status: 'rejected', reason: result.reason }); +} + +function settleCancelAndClose( + cancel: Promise, + close: Promise +): Promise<[ + PromiseSettledResult, + PromiseSettledResult, +]> { + return new ControllerPromise(resolve => { + let remaining = 2; + let cancelResult!: PromiseSettledResult; + let closeResult!: PromiseSettledResult; + const settled = () => { + remaining--; + if (remaining === 0) resolve([cancelResult, closeResult]); + }; + Reflect.apply(controllerPromiseThen, cancel, [ + (value: ManagedRunCancelReceiptV1) => { + cancelResult = { status: 'fulfilled', value }; + settled(); + }, + (reason: unknown) => { + cancelResult = { status: 'rejected', reason }; + settled(); + }, + ]); + Reflect.apply(controllerPromiseThen, close, [ + (value: ManagedRunCleanupReceiptV1) => { + closeResult = { status: 'fulfilled', value }; + settled(); + }, + (reason: unknown) => { + closeResult = { status: 'rejected', reason }; + settled(); + }, + ]); + }); +} + +/** + * Arm the managed run's sole deadline timer. Once the journal callback commits, + * cancel and close are invoked back-to-back before either is awaited. + */ +export function armManagedRunDeadline(input: { + readonly snapshot: ManagedRunSnapshot; + readonly timeoutMs: number; + readonly onCancelRequested: () => void; +}): ManagedRunDeadline { + let didFire = false; + let cleared = false; + let timer: ReturnType | undefined; + const fired = new ControllerPromise(resolve => { + timer = setTimeout(() => { + didFire = true; + let cancelRequested = true; + try { + input.onCancelRequested(); + } catch { + cancelRequested = false; + } + + let cancel: Promise | undefined; + if (cancelRequested) { + try { + cancel = input.snapshot.cancelOnce('deadline', input.snapshot.binding.fence); + } catch { + cancel = controllerRejected(protocolError('MANAGED_CANCEL_FAILED')); + } + } + let close: Promise; + try { + close = input.snapshot.closeOnce(); + } catch { + close = controllerRejected(protocolError('MANAGED_CLOSE_FAILED')); + } + if (cancel) observeRejection(cancel); + observeRejection(close); + + if (!cancel) { + void Reflect.apply(controllerPromiseThen, close, [ + (value: ManagedRunCleanupReceiptV1) => resolve(Object.freeze({ + cancel: null, + close: frozenSettledResult({ status: 'fulfilled', value }), + cancelRequested: false, + })), + (reason: unknown) => resolve(Object.freeze({ + cancel: null, + close: frozenSettledResult({ + status: 'rejected', + reason, + }), + cancelRequested: false, + })), + ]); + return; + } + + void Reflect.apply(controllerPromiseThen, settleCancelAndClose(cancel, close), [ + (results: [ + PromiseSettledResult, + PromiseSettledResult, + ]) => resolve(Object.freeze({ + cancel: frozenSettledResult(results[0]), + close: frozenSettledResult(results[1]), + cancelRequested: true, + })), + ]); + }, normalizeManagedRunTimeout(input.timeoutMs)); + }); + observeRejection(fired); + + return Object.freeze({ + fired, + didFire: () => didFire, + clear: () => { + if (cleared || didFire) return; + cleared = true; + if (timer !== undefined) clearTimeout(timer); + }, + }); +} diff --git a/src/state-machine/graph/claim-kernel.ts b/src/state-machine/graph/claim-kernel.ts new file mode 100644 index 000000000..d39850771 --- /dev/null +++ b/src/state-machine/graph/claim-kernel.ts @@ -0,0 +1,557 @@ +import { createHash } from 'crypto'; +import Ajv, { type ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; +import type { CandidateClaimInput } from '../../providers/check-provider.interface'; +import type { ScopePath } from '../../snapshot-store'; +import type { ClaimPlan } from './claim-plan'; + +export type ClaimRuntimeEvent = + | AttemptStartedEvent + | ClaimPublishedEvent + | CheckScheduledEvent + | AttemptCompletedEvent + | AttemptFailedEvent; + +interface RuntimeEventBase { + readonly version: 1; + readonly eventId: number; + readonly sessionId: string; + readonly checkId: string; + readonly scope: ScopePath; + readonly attemptId: string; + readonly fence: number; +} + +export interface AttemptStartedEvent extends RuntimeEventBase { + readonly type: 'AttemptStarted'; +} + +export interface ClaimPublishedEvent extends RuntimeEventBase { + readonly type: 'ClaimPublished'; + readonly claimId: string; + readonly claim: string; + readonly payload: unknown; + readonly payloadFingerprint: string; + readonly producerCheckId: string; + readonly parentClaimIds: readonly string[]; +} + +export interface CheckScheduledEvent extends RuntimeEventBase { + readonly type: 'CheckScheduled'; + readonly claimIds: readonly string[]; +} + +export interface AttemptCompletedEvent extends RuntimeEventBase { + readonly type: 'AttemptCompleted'; +} + +export interface AttemptFailedEvent extends RuntimeEventBase { + readonly type: 'AttemptFailed'; + readonly reason: string; +} + +export interface AttemptProjection { + readonly sessionId: string; + readonly checkId: string; + readonly scope: ScopePath; + readonly attemptId: string; + readonly fence: number; + readonly status: 'started' | 'completed' | 'failed'; + readonly reason?: string; +} + +export interface ClaimProjection { + readonly lastEventId: number; + readonly attempts: Readonly>; + readonly claims: Readonly>; + readonly activeClaimIdsByRef: Readonly>; + readonly scheduled: readonly CheckScheduledEvent[]; +} + +export type ClaimSchemaValidator = (payload: unknown) => void; + +export class ClaimKernelError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'ClaimKernelError'; + this.code = code; + } +} + +function assertJsonValue(value: unknown, seen: Set): string { + if (value === null) return 'null'; + if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new ClaimKernelError('NON_CANONICAL_JSON', 'Non-finite numbers are not canonical JSON'); + } + return JSON.stringify(value); + } + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' || + typeof value === 'bigint' + ) { + throw new ClaimKernelError( + 'NON_CANONICAL_JSON', + `Unsupported canonical JSON value: ${typeof value}` + ); + } + if (typeof value !== 'object') { + throw new ClaimKernelError('NON_CANONICAL_JSON', 'Unsupported canonical JSON value'); + } + if (seen.has(value)) { + throw new ClaimKernelError('NON_CANONICAL_JSON', 'Cyclic values are not canonical JSON'); + } + seen.add(value); + try { + if (Array.isArray(value)) { + return `[${value.map(item => assertJsonValue(item, seen)).join(',')}]`; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new ClaimKernelError( + 'NON_CANONICAL_JSON', + 'Only plain objects are canonical JSON objects' + ); + } + const record = value as Record; + return `{${Object.keys(record) + .sort() + .map(key => `${JSON.stringify(key)}:${assertJsonValue(record[key], seen)}`) + .join(',')}}`; + } finally { + seen.delete(value); + } +} + +/** Deterministic UTF-8 JSON with recursively sorted object keys. */ +export function canonicalJson(value: unknown): string { + return assertJsonValue(value, new Set()); +} + +export function sha256Canonical(value: unknown): string { + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); +} + +function freezeJson(value: unknown): unknown { + if (value && typeof value === 'object') { + for (const child of Object.values(value as Record)) freezeJson(child); + Object.freeze(value); + } + return value; +} + +export function immutableCanonicalValue(value: T): T { + return freezeJson(JSON.parse(canonicalJson(value))) as T; +} + +export function immutableRuntimeEvent(event: T): T { + return immutableCanonicalValue(event); +} + +export function attemptProjectionKey( + sessionId: string, + checkId: string, + scope: ScopePath +): string { + return sha256Canonical({ sessionId, checkId, scope }); +} + +export function createInitialClaimProjection(): ClaimProjection { + return immutableCanonicalValue({ + lastEventId: 0, + attempts: {}, + claims: {}, + activeClaimIdsByRef: {}, + scheduled: [], + }); +} + +function cloneScope(scope: ScopePath): ScopePath { + return scope.map(part => ({ ...part })); +} + +function requireActivePlan(plan: ClaimPlan): void { + if (!plan.active) { + throw new ClaimKernelError('CLAIM_MODE_INACTIVE', 'Runtime claim events require claim mode'); + } +} + +function requireRootScope(scope: ScopePath): void { + if (scope.length !== 0) { + throw new ClaimKernelError('UNSUPPORTED_CLAIM_SCOPE', 'Graph v2 C1 supports root scope only'); + } +} + +function requireCurrentAttempt( + projection: ClaimProjection, + event: RuntimeEventBase +): AttemptProjection { + const key = attemptProjectionKey(event.sessionId, event.checkId, event.scope); + const current = projection.attempts[key]; + if ( + !current || + current.status !== 'started' || + current.attemptId !== event.attemptId || + current.fence !== event.fence + ) { + throw new ClaimKernelError( + 'STALE_FENCE', + `Attempt ${event.attemptId} fence ${event.fence} is not current for ${event.checkId}` + ); + } + return current; +} + +function sameIds(actual: readonly string[], expected: readonly string[]): boolean { + return actual.length === expected.length && actual.every((value, index) => value === expected[index]); +} + +export function exactActiveClaimIds( + plan: ClaimPlan, + projection: ClaimProjection, + checkId: string +): readonly string[] { + requireActivePlan(plan); + if (!Object.prototype.hasOwnProperty.call(plan.effectiveDependenciesByCheck, checkId)) { + throw new ClaimKernelError('UNKNOWN_CHECK', `Unknown claim-mode check ${checkId}`); + } + const ids = (plan.consumptionsByCheck[checkId] || []).map(consumption => { + const claimId = projection.activeClaimIdsByRef[consumption.claim]; + const claim = claimId ? projection.claims[claimId] : undefined; + if (!claim || claim.claim !== consumption.claim) { + throw new ClaimKernelError( + 'CLAIM_NOT_READY', + `Check ${checkId} requires active claim ${consumption.claim}` + ); + } + return claimId; + }); + return Object.freeze(ids); +} + +function exactScheduledParentIds( + plan: ClaimPlan, + projection: ClaimProjection, + event: RuntimeEventBase +): readonly string[] { + const scheduled = projection.scheduled.find( + candidate => + candidate.sessionId === event.sessionId && + candidate.checkId === event.checkId && + candidate.attemptId === event.attemptId && + candidate.fence === event.fence && + canonicalJson(candidate.scope) === canonicalJson(event.scope) + ); + if (!scheduled) { + throw new ClaimKernelError( + 'ATTEMPT_NOT_SCHEDULED', + `Attempt ${event.attemptId} was not scheduled before terminal processing` + ); + } + const active = exactActiveClaimIds(plan, projection, event.checkId); + if (!sameIds(scheduled.claimIds, active)) { + throw new ClaimKernelError( + 'INACTIVE_PARENT_CLAIM', + `Attempt ${event.attemptId} no longer has its exact active parent claims` + ); + } + return scheduled.claimIds; +} + +function requireDeclaredPublication( + plan: ClaimPlan, + projection: ClaimProjection, + event: ClaimPublishedEvent +): void { + const emissions = plan.emissionsByCheck[event.checkId] || []; + if ( + plan.emitterByClaim[event.claim] !== event.checkId || + event.producerCheckId !== event.checkId || + !emissions.some(emission => emission.claim === event.claim) + ) { + throw new ClaimKernelError( + 'UNDECLARED_CLAIM_PUBLICATION', + `Check ${event.checkId} is not the declared emitter of ${event.claim}` + ); + } + + const parentClaimIds = exactScheduledParentIds(plan, projection, event); + if (!sameIds(event.parentClaimIds, parentClaimIds)) { + throw new ClaimKernelError( + 'INVALID_PARENT_CLAIMS', + `Published claim ${event.claim} does not carry the attempt's exact parent claims` + ); + } + + plan.validatorsByClaim[event.claim](event.payload); + const payloadFingerprint = sha256Canonical(event.payload); + if (event.payloadFingerprint !== payloadFingerprint) { + throw new ClaimKernelError( + 'INVALID_PAYLOAD_FINGERPRINT', + `Published claim ${event.claim} has an invalid payload fingerprint` + ); + } + const expectedClaimId = sha256Canonical({ + claim: event.claim, + payloadFingerprint, + producerCheckId: event.checkId, + scope: event.scope, + attemptId: event.attemptId, + fence: event.fence, + parentClaimIds: [...parentClaimIds].sort(), + }); + if (event.claimId !== expectedClaimId) { + throw new ClaimKernelError( + 'INVALID_CLAIM_ID', + `Published claim ${event.claim} has an invalid claim ID` + ); + } +} + +function requireAllDeclaredEmissions( + plan: ClaimPlan, + projection: ClaimProjection, + event: AttemptCompletedEvent +): void { + for (const emission of plan.emissionsByCheck[event.checkId] || []) { + const claimId = projection.activeClaimIdsByRef[emission.claim]; + const claim = claimId ? projection.claims[claimId] : undefined; + if ( + !claim || + claim.producerCheckId !== event.checkId || + claim.attemptId !== event.attemptId || + claim.fence !== event.fence + ) { + throw new ClaimKernelError( + 'INCOMPLETE_CLAIM_PUBLICATION', + `Attempt ${event.attemptId} did not publish every declared claim` + ); + } + } +} + +function hasPublishedClaimForAttempt( + projection: ClaimProjection, + event: AttemptFailedEvent +): boolean { + return Object.values(projection.claims).some( + claim => + claim.producerCheckId === event.checkId && + claim.attemptId === event.attemptId && + claim.fence === event.fence + ); +} + +/** Pure plan-aware transition reducer. It returns a deeply immutable projection. */ +export function reduceClaimEvent( + projection: ClaimProjection, + event: ClaimRuntimeEvent, + plan: ClaimPlan +): ClaimProjection { + requireActivePlan(plan); + requireRootScope(event.scope); + if (event.version !== 1) { + throw new ClaimKernelError('UNSUPPORTED_EVENT_VERSION', 'Unsupported event version'); + } + if (!Number.isSafeInteger(event.eventId) || event.eventId <= projection.lastEventId) { + throw new ClaimKernelError( + 'NON_MONOTONIC_EVENT', + `Claim event ${event.eventId} must advance beyond ${projection.lastEventId}` + ); + } + if (!Object.prototype.hasOwnProperty.call(plan.effectiveDependenciesByCheck, event.checkId)) { + throw new ClaimKernelError('UNKNOWN_CHECK', `Unknown claim-mode check ${event.checkId}`); + } + + const next = { + lastEventId: event.eventId, + attempts: { ...projection.attempts }, + claims: { ...projection.claims }, + activeClaimIdsByRef: { ...projection.activeClaimIdsByRef }, + scheduled: [...projection.scheduled], + }; + const attemptKey = attemptProjectionKey(event.sessionId, event.checkId, event.scope); + + switch (event.type) { + case 'AttemptStarted': { + const current = projection.attempts[attemptKey]; + if (current && event.fence <= current.fence) { + throw new ClaimKernelError('STALE_FENCE', 'Attempt fence must advance monotonically'); + } + if (Object.values(projection.attempts).some(attempt => attempt.attemptId === event.attemptId)) { + throw new ClaimKernelError('DUPLICATE_ATTEMPT', `Attempt ${event.attemptId} already exists`); + } + next.attempts[attemptKey] = { + sessionId: event.sessionId, + checkId: event.checkId, + scope: cloneScope(event.scope), + attemptId: event.attemptId, + fence: event.fence, + status: 'started', + }; + break; + } + case 'ClaimPublished': { + requireCurrentAttempt(projection, event); + requireDeclaredPublication(plan, projection, event); + if (projection.claims[event.claimId]) { + throw new ClaimKernelError('DUPLICATE_CLAIM', `Claim ${event.claimId} already exists`); + } + const claim: CandidateClaimInput = { + claimId: event.claimId, + claim: event.claim, + payload: immutableCanonicalValue(event.payload), + payloadFingerprint: event.payloadFingerprint, + producerCheckId: event.producerCheckId, + scope: cloneScope(event.scope), + attemptId: event.attemptId, + fence: event.fence, + parentClaimIds: [...event.parentClaimIds], + }; + next.claims[event.claimId] = claim; + next.activeClaimIdsByRef[event.claim] = event.claimId; + break; + } + case 'CheckScheduled': { + requireCurrentAttempt(projection, event); + const expected = exactActiveClaimIds(plan, projection, event.checkId); + if (new Set(event.claimIds).size !== event.claimIds.length || !sameIds(event.claimIds, expected)) { + throw new ClaimKernelError( + 'INVALID_SCHEDULED_CLAIMS', + `Check ${event.checkId} was not scheduled with its exact declared active claims` + ); + } + next.scheduled.push(event); + break; + } + case 'AttemptCompleted': { + const current = requireCurrentAttempt(projection, event); + exactScheduledParentIds(plan, projection, event); + requireAllDeclaredEmissions(plan, projection, event); + next.attempts[attemptKey] = { ...current, status: 'completed' }; + break; + } + case 'AttemptFailed': { + const current = requireCurrentAttempt(projection, event); + if (hasPublishedClaimForAttempt(projection, event)) { + throw new ClaimKernelError( + 'PARTIAL_CLAIM_PUBLICATION', + `Failed attempt ${event.attemptId} cannot retain published claims` + ); + } + next.attempts[attemptKey] = { ...current, status: 'failed', reason: event.reason }; + break; + } + } + return immutableCanonicalValue(next); +} + +export function replayClaimEvents( + events: readonly ClaimRuntimeEvent[], + plan: ClaimPlan +): ClaimProjection { + return events.reduce( + (projection, event) => reduceClaimEvent(projection, event, plan), + createInitialClaimProjection() + ); +} + +function formatValidationErrors(validate: ValidateFunction): string { + return (validate.errors || []) + .map(error => `${error.instancePath || '/'} ${error.message || 'is invalid'}`) + .join('; '); +} + +/** Compile a claim schema once, strictly, before any provider may launch. */ +export function compileClaimSchema(schema: Record): ClaimSchemaValidator { + const ajv = new Ajv({ + allErrors: true, + allowUnionTypes: true, + strict: true, + coerceTypes: false, + useDefaults: false, + removeAdditional: false, + }); + addFormats(ajv); + let validate: ValidateFunction; + try { + validate = ajv.compile(schema); + } catch (error) { + throw new ClaimKernelError( + 'INVALID_CLAIM_SCHEMA', + `Invalid claim schema: ${error instanceof Error ? error.message : String(error)}` + ); + } + return Object.freeze((payload: unknown): void => { + canonicalJson(payload); + if (!validate(payload)) { + const detail = formatValidationErrors(validate); + throw new ClaimKernelError( + 'CLAIM_SCHEMA_INVALID', + `Candidate claim payload failed schema validation${detail ? `: ${detail}` : ''}` + ); + } + }); +} + +export function buildClaimPublishedEvent(input: { + eventId: number; + sessionId: string; + checkId: string; + scope: ScopePath; + attemptId: string; + fence: number; + claim: string; + payload: unknown; + parentClaimIds: readonly string[]; + projection: ClaimProjection; + plan: ClaimPlan; +}): ClaimPublishedEvent { + requireCurrentAttempt(input.projection, { + version: 1, + eventId: input.eventId, + sessionId: input.sessionId, + checkId: input.checkId, + scope: input.scope, + attemptId: input.attemptId, + fence: input.fence, + }); + const validator = input.plan.validatorsByClaim[input.claim]; + if (!validator) { + throw new ClaimKernelError('UNDECLARED_CLAIM_PUBLICATION', `Unknown claim ${input.claim}`); + } + validator(input.payload); + const payload = immutableCanonicalValue(input.payload); + const payloadFingerprint = sha256Canonical(payload); + const parentClaimIds = [...input.parentClaimIds]; + const claimId = sha256Canonical({ + claim: input.claim, + payloadFingerprint, + producerCheckId: input.checkId, + scope: input.scope, + attemptId: input.attemptId, + fence: input.fence, + parentClaimIds: [...parentClaimIds].sort(), + }); + return { + version: 1, + type: 'ClaimPublished', + eventId: input.eventId, + sessionId: input.sessionId, + checkId: input.checkId, + producerCheckId: input.checkId, + scope: cloneScope(input.scope), + attemptId: input.attemptId, + fence: input.fence, + claimId, + claim: input.claim, + payload, + payloadFingerprint, + parentClaimIds, + }; +} diff --git a/src/state-machine/graph/claim-plan.ts b/src/state-machine/graph/claim-plan.ts new file mode 100644 index 000000000..030238ae2 --- /dev/null +++ b/src/state-machine/graph/claim-plan.ts @@ -0,0 +1,316 @@ +import type { + CheckConfig, + ClaimConsumptionConfig, + ClaimEmissionConfig, + ClaimTypeConfig, + VisorConfig, +} from '../../types/config'; +import { + compileClaimSchema, + immutableCanonicalValue, + type ClaimSchemaValidator, +} from './claim-kernel'; +import { + compileExpansionPlan, + PROOF_ADMIT_PROVIDER_TYPE, + PROOF_ADMITTED_RECEIPT_CLAIM, + PROOF_CANDIDATE_CLAIM, + type ExpansionPlan, +} from './instance-plan'; + +export const CLAIM_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*@[1-9][0-9]*$/; + +export class ClaimPlanError extends Error { + readonly code: string; + + constructor(message: string, code = 'INVALID_CLAIM_PLAN') { + super(message); + this.name = 'ClaimPlanError'; + this.code = code; + } +} + +export interface ClaimPlan { + readonly active: boolean; + readonly expansionPlan: ExpansionPlan; + readonly claimTypes: Readonly>; + readonly validatorsByClaim: Readonly>; + readonly emitterByClaim: Readonly>; + readonly emissionsByCheck: Readonly>; + readonly consumptionsByCheck: Readonly>; + readonly effectiveDependenciesByCheck: Readonly>; +} + +function freezeRecord(record: Record): Readonly> { + return Object.freeze(record); +} + +function dependencyTokens(check: CheckConfig): string[] { + const raw = check.depends_on; + return (Array.isArray(raw) ? raw : raw ? [raw] : []).flatMap(token => + token.includes('|') + ? token + .split('|') + .map(value => value.trim()) + .filter(Boolean) + : [token] + ); +} + +function authoredDependencyTokens(check: CheckConfig): string[] { + const raw = check.depends_on; + return Array.isArray(raw) ? raw : raw ? [raw] : []; +} + +function hasOwn(record: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function assertAcyclic(dependencies: Record): void { + const visiting = new Set(); + const visited = new Set(); + const trail: string[] = []; + + const visit = (checkId: string): void => { + if (visited.has(checkId)) return; + if (visiting.has(checkId)) { + const start = trail.indexOf(checkId); + const cycle = [...trail.slice(Math.max(0, start)), checkId].join(' -> '); + throw new ClaimPlanError(`Claim dependency cycle detected: ${cycle}`); + } + visiting.add(checkId); + trail.push(checkId); + for (const dependency of dependencies[checkId] || []) { + if (Object.prototype.hasOwnProperty.call(dependencies, dependency)) visit(dependency); + } + trail.pop(); + visiting.delete(checkId); + visited.add(checkId); + }; + + for (const checkId of Object.keys(dependencies).sort()) visit(checkId); +} + +/** + * Compile authored claim declarations into immutable exact data bindings and + * effective terminal dependencies. The authored configuration is never mutated. + */ +export function compileClaimPlan(config: Partial): ClaimPlan { + const checks = config.checks || config.steps || {}; + const claimTypes = config.claim_types || {}; + let hasClaimDeclarations = false; + for (const [checkId, check] of Object.entries(checks)) { + for (const field of ['emits', 'consumes'] as const) { + if (!hasOwn(check, field)) continue; + hasClaimDeclarations = true; + const declarations = check[field]; + if (!Array.isArray(declarations) || declarations.length === 0) { + throw new ClaimPlanError( + `Check "${checkId}" declares ${field}, which must be a non-empty array`, + 'EMPTY_CLAIM_DECLARATION' + ); + } + } + } + + // The reserved admission profile is template-only. Check this before the + // inactive-plan fast path so a root provider type cannot bypass policy by + // omitting claim_types (and so reserved root claims cannot become controller + // inputs or root emissions). + for (const [checkId, check] of Object.entries(checks)) { + if (check.type === PROOF_ADMIT_PROVIDER_TYPE) { + throw new ClaimPlanError( + `Root check "${checkId}" cannot use reserved provider type ${PROOF_ADMIT_PROVIDER_TYPE}`, + 'RESERVED_PROOF_ADMISSION_ROOT' + ); + } + for (const field of ['emits', 'consumes'] as const) { + if ((check[field] || []).some(declaration => + declaration.claim === PROOF_CANDIDATE_CLAIM || + declaration.claim === PROOF_ADMITTED_RECEIPT_CLAIM + )) { + throw new ClaimPlanError( + `Root check "${checkId}" cannot declare reserved claim ${PROOF_CANDIDATE_CLAIM} or ${PROOF_ADMITTED_RECEIPT_CLAIM}`, + 'RESERVED_PROOF_ADMISSION_ROOT' + ); + } + } + const expansion = check.expand; + if ( + expansion && + (expansion.claim === PROOF_CANDIDATE_CLAIM || + expansion.claim === PROOF_ADMITTED_RECEIPT_CLAIM || + expansion.item_claim === PROOF_CANDIDATE_CLAIM || + expansion.item_claim === PROOF_ADMITTED_RECEIPT_CLAIM) + ) { + throw new ClaimPlanError( + `Root check "${checkId}" cannot route a reserved proof admission claim through expansion`, + 'RESERVED_PROOF_ADMISSION_ROOT' + ); + } + } + + const active = Object.keys(claimTypes).length > 0; + + if (!active && hasClaimDeclarations) { + throw new ClaimPlanError('Claim declarations require a non-empty top-level claim_types map'); + } + if (!active) { + const effective: Record = {}; + for (const [checkId, check] of Object.entries(checks)) { + effective[checkId] = Object.freeze(dependencyTokens(check)); + } + const expansionPlan = compileExpansionPlan(config, { + claimTypes: {}, + validatorsByClaim: {}, + rootEmitterByClaim: {}, + }); + return Object.freeze({ + active: false, + expansionPlan, + claimTypes: freezeRecord({}), + validatorsByClaim: freezeRecord({}), + emitterByClaim: freezeRecord({}), + emissionsByCheck: freezeRecord({}), + consumptionsByCheck: freezeRecord({}), + effectiveDependenciesByCheck: freezeRecord(effective), + }); + } + + const immutableClaimTypes: Record = {}; + const validatorsByClaim: Record = {}; + for (const [claim, definition] of Object.entries(claimTypes)) { + if (!CLAIM_REF_PATTERN.test(claim)) { + throw new ClaimPlanError( + `Invalid claim reference "${claim}"; expected @` + ); + } + if ( + !definition || + typeof definition !== 'object' || + !definition.schema || + typeof definition.schema !== 'object' || + Array.isArray(definition.schema) + ) { + throw new ClaimPlanError(`Claim type "${claim}" requires a JSON Schema`); + } + const schema = immutableCanonicalValue(definition.schema); + immutableClaimTypes[claim] = Object.freeze({ schema }); + validatorsByClaim[claim] = compileClaimSchema(schema); + } + + const emitterByClaim: Record = {}; + const emissionsByCheck: Record = {}; + const consumptionsByCheck: Record = {}; + + for (const [checkId, check] of Object.entries(checks)) { + const emissions = check.emits || []; + const consumptions = check.consumes || []; + if (emissions.length === 0 && consumptions.length === 0) continue; + if (check.forEach || check.type === 'workflow') { + throw new ClaimPlanError( + `Graph v2 C1 claim declarations are root-scope only; check "${checkId}" cannot use forEach or workflow` + ); + } + if (emissions.length > 0) { + emissionsByCheck[checkId] = Object.freeze( + emissions.map(emission => Object.freeze({ ...emission })) + ); + } + if (consumptions.length > 0) { + consumptionsByCheck[checkId] = Object.freeze( + consumptions.map(consumption => Object.freeze({ ...consumption })) + ); + } + + for (const emission of emissions) { + if (!CLAIM_REF_PATTERN.test(emission.claim)) { + throw new ClaimPlanError(`Invalid emitted claim reference "${emission.claim}"`); + } + if (!Object.prototype.hasOwnProperty.call(claimTypes, emission.claim)) { + throw new ClaimPlanError( + `Check "${checkId}" emits undeclared claim "${emission.claim}"` + ); + } + if (emission.from !== 'output') { + throw new ClaimPlanError( + `Check "${checkId}" uses unsupported claim source "${String(emission.from)}"` + ); + } + const existing = emitterByClaim[emission.claim]; + if (existing) { + throw new ClaimPlanError( + `Claim "${emission.claim}" has duplicate emitters "${existing}" and "${checkId}"` + ); + } + emitterByClaim[emission.claim] = checkId; + } + + const seenConsumes = new Set(); + for (const consumption of consumptions) { + if (!CLAIM_REF_PATTERN.test(consumption.claim)) { + throw new ClaimPlanError(`Invalid consumed claim reference "${consumption.claim}"`); + } + if (!Object.prototype.hasOwnProperty.call(claimTypes, consumption.claim)) { + throw new ClaimPlanError( + `Check "${checkId}" consumes undeclared claim "${consumption.claim}"` + ); + } + if (consumption.cardinality !== 'one') { + throw new ClaimPlanError( + `Check "${checkId}" uses unsupported claim cardinality "${String(consumption.cardinality)}"` + ); + } + if (seenConsumes.has(consumption.claim)) { + throw new ClaimPlanError( + `Check "${checkId}" consumes claim "${consumption.claim}" more than once` + ); + } + seenConsumes.add(consumption.claim); + } + } + + for (const [checkId, check] of Object.entries(checks)) { + const orToken = authoredDependencyTokens(check).find(token => token.includes('|')); + if (orToken) { + throw new ClaimPlanError( + `Graph v2 C1 does not support OR dependency token "${orToken}" on check "${checkId}"`, + 'UNSUPPORTED_CLAIM_OR_DEPENDENCY' + ); + } + } + + const effectiveDependenciesByCheck: Record = {}; + for (const [checkId, check] of Object.entries(checks)) { + const effective = new Set(dependencyTokens(check)); + for (const consumption of consumptionsByCheck[checkId] || []) { + const emitter = emitterByClaim[consumption.claim]; + if (!emitter) { + throw new ClaimPlanError( + `Claim "${consumption.claim}" consumed by "${checkId}" has no emitter` + ); + } + effective.add(emitter); + } + effectiveDependenciesByCheck[checkId] = Object.freeze([...effective]); + } + + assertAcyclic(effectiveDependenciesByCheck); + + const expansionPlan = compileExpansionPlan(config, { + claimTypes: immutableClaimTypes, + validatorsByClaim, + rootEmitterByClaim: emitterByClaim, + }); + + return Object.freeze({ + active: true, + expansionPlan, + claimTypes: freezeRecord(immutableClaimTypes), + validatorsByClaim: freezeRecord(validatorsByClaim), + emitterByClaim: freezeRecord(emitterByClaim), + emissionsByCheck: freezeRecord(emissionsByCheck), + consumptionsByCheck: freezeRecord(consumptionsByCheck), + effectiveDependenciesByCheck: freezeRecord(effectiveDependenciesByCheck), + }); +} diff --git a/src/state-machine/graph/instance-kernel.ts b/src/state-machine/graph/instance-kernel.ts new file mode 100644 index 000000000..8f96b4636 --- /dev/null +++ b/src/state-machine/graph/instance-kernel.ts @@ -0,0 +1,2353 @@ +import { + attemptProjectionKey, + canonicalJson, + immutableCanonicalValue, + sha256Canonical, + type ClaimProjection, +} from './claim-kernel'; +import { resolveJsonPointer, type CompiledExpansion } from './instance-plan'; + +export interface IndexedScopeSegment { + readonly kind: 'indexed'; + readonly check: string; + readonly index: number; +} + +export interface KeyedScopeSegment { + readonly kind: 'keyed'; + readonly expansionOwnerCheck: string; + readonly key: string; + readonly subgraphInstanceId: string; +} + +export type TaggedScopeSegment = IndexedScopeSegment | KeyedScopeSegment; +export type TaggedScopePath = readonly TaggedScopeSegment[]; +export type RootScopePath = readonly []; +export type LevelOneKeyedScopePath = readonly [KeyedScopeSegment]; +export type LevelTwoKeyedScopePath = readonly [KeyedScopeSegment, KeyedScopeSegment]; +export type KeyedScopePath = LevelOneKeyedScopePath | LevelTwoKeyedScopePath; + +export type NodeGenerationStatus = 'ready' | 'running' | 'completed' | 'failed' | 'inactive'; +export type CatalogRequestStatus = 'pending' | 'running' | 'completed' | 'failed'; + +export class InstanceKernelError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'InstanceKernelError'; + this.code = code; + } +} + +const SHA256_PATTERN = /^[0-9a-f]{64}$/; + +function hasExactKeys(value: Record, expected: readonly string[]): boolean { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]); +} + +function requireNonEmpty(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new InstanceKernelError('INVALID_SCOPE', `${label} must be a non-empty string`); + } +} + +/** Canonical catalog keys deliberately collapse a number and its string form. */ +export function canonicalCatalogKey(value: unknown): string { + if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'number' && Number.isFinite(value)) return canonicalJson(value); + throw new InstanceKernelError( + 'INVALID_ITEM_KEY', + 'Catalog item key must be a non-empty string or finite number' + ); +} + +/** + * Validate and clone a tagged scope. Root and legacy indexed paths are valid; + * a graph-v2 keyed path has exactly one or two segments. Mixed paths fail closed. + */ +export function validateTaggedScopePath(value: unknown): TaggedScopePath { + if (!Array.isArray(value)) { + throw new InstanceKernelError('INVALID_SCOPE', 'Scope path must be an array'); + } + if (value.length === 0) return Object.freeze([]); + + const segments: TaggedScopeSegment[] = value.map((candidate, position) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) { + throw new InstanceKernelError('INVALID_SCOPE', `Scope segment ${position} must be an object`); + } + const segment = candidate as Record; + if (segment.kind === 'indexed') { + if (!hasExactKeys(segment, ['kind', 'check', 'index'])) { + throw new InstanceKernelError('INVALID_SCOPE', 'Indexed scope segment has unknown fields'); + } + requireNonEmpty(segment.check, 'Indexed scope check'); + if ( + typeof segment.index !== 'number' || + !Number.isSafeInteger(segment.index) || + segment.index < 0 + ) { + throw new InstanceKernelError( + 'INVALID_SCOPE', + 'Indexed scope index must be a safe non-negative integer' + ); + } + return Object.freeze({ kind: 'indexed', check: segment.check, index: segment.index }); + } + if (segment.kind === 'keyed') { + if ( + !hasExactKeys(segment, [ + 'kind', + 'expansionOwnerCheck', + 'key', + 'subgraphInstanceId', + ]) + ) { + throw new InstanceKernelError('INVALID_SCOPE', 'Keyed scope segment has unknown fields'); + } + requireNonEmpty(segment.expansionOwnerCheck, 'Keyed scope expansion owner'); + requireNonEmpty(segment.key, 'Keyed scope key'); + if (typeof segment.subgraphInstanceId !== 'string' || !SHA256_PATTERN.test(segment.subgraphInstanceId)) { + throw new InstanceKernelError( + 'INVALID_SCOPE', + 'Keyed scope subgraph instance ID must be lowercase SHA-256' + ); + } + return Object.freeze({ + kind: 'keyed', + expansionOwnerCheck: segment.expansionOwnerCheck, + key: segment.key, + subgraphInstanceId: segment.subgraphInstanceId, + }); + } + throw new InstanceKernelError('INVALID_SCOPE', `Scope segment ${position} is not tagged`); + }); + + const kinds = new Set(segments.map(segment => segment.kind)); + if (kinds.size !== 1) { + throw new InstanceKernelError('INVALID_SCOPE', 'Indexed and keyed scope segments cannot mix'); + } + if (segments[0].kind === 'keyed' && segments.length > 2) { + throw new InstanceKernelError('INVALID_SCOPE', 'Graph v2 supports at most two keyed scope segments'); + } + return Object.freeze(segments); +} + +export function requireRootScopePath(value: unknown): RootScopePath { + const scope = validateTaggedScopePath(value); + if (scope.length !== 0) { + throw new InstanceKernelError('INVALID_SCOPE', 'Expected exact root scope'); + } + return scope as RootScopePath; +} + +export function requireKeyedScopePath( + value: unknown, + expected?: KeyedScopeSegment | KeyedScopePath +): KeyedScopePath { + const scope = validateTaggedScopePath(value); + if ((scope.length !== 1 && scope.length !== 2) || scope.some(segment => segment.kind !== 'keyed')) { + throw new InstanceKernelError('INVALID_SCOPE', 'Expected exact graph-v2 keyed scope'); + } + const expectedScope = expected + ? Array.isArray(expected) ? expected : [expected] + : undefined; + if (expectedScope && !scopePathEquals(scope, expectedScope)) { + throw new InstanceKernelError( + 'INVALID_SCOPE', + 'Keyed scope does not match its complete projected ancestor chain' + ); + } + return scope as KeyedScopePath; +} + +export function scopePathEquals(left: unknown, right: unknown): boolean { + return canonicalJson(validateTaggedScopePath(left)) === canonicalJson(validateTaggedScopePath(right)); +} + +export function deriveSubgraphInstanceId(input: + | { + readonly graphSemanticDigest: string; + readonly expansionOwnerCheck: string; + readonly parentSubgraphInstanceId: null; + readonly templateDigest: string; + readonly itemKey: string; + } + | { + readonly graphSemanticDigest: string; + readonly parentSubgraphInstanceId: string; + readonly expansionOwnerNodeInstanceId: string; + readonly templateDigest: string; + readonly itemKey: string; + } +): string { + return sha256Canonical({ v: 1, ...input }); +} + +export function deriveNodeInstanceId(input: { + readonly subgraphInstanceId: string; + readonly templateNodeKey: string; +}): string { + return sha256Canonical({ v: 1, ...input }); +} + +export function deriveItemFingerprint(payload: unknown): string { + return sha256Canonical(payload); +} + +export function deriveNodeGenerationId(input: { + readonly nodeInstanceId: string; + readonly incarnation: number; + readonly itemFingerprint: string; + readonly executionConfigDigest: string; + readonly activeInputClaimIds: readonly string[]; +}): string { + return sha256Canonical({ + v: 1, + nodeInstanceId: input.nodeInstanceId, + incarnation: input.incarnation, + itemFingerprint: input.itemFingerprint, + executionConfigDigest: input.executionConfigDigest, + activeInputClaimIds: [...input.activeInputClaimIds].sort(), + }); +} + +export function deriveControllerItemClaimId(input: { + readonly claim: string; + readonly payloadFingerprint: string; + readonly expansionSpecDigest: string; + readonly catalogClaimId: string; + readonly subgraphInstanceId: string; + readonly incarnation: number; + readonly scope: KeyedScopePath; +}): string { + return sha256Canonical({ v: 1, type: 'controller-item', ...input }); +} + +export function deriveCatalogRequestId(input: { + readonly sessionId: string; + readonly expansionOwnerCheck: string; + readonly ordinal: number; +}): string { + return sha256Canonical({ v: 1, type: 'catalog-reconciliation', ...input }); +} + +export interface ManagedRunBindingV1 { + readonly managedRunId: string; + readonly sessionId: string; + readonly checkId: string; + readonly scope: KeyedScopePath; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; + readonly attemptId: string; + readonly fence: number; +} + +export type ManagedRunCleanupStatus = 'clean' | 'unverified'; +export type ManagedRunControllerDecision = 'completed' | 'failed'; +export type ManagedRunFailureCode = + | 'MANAGED_HANDLE_INVALID' + | 'MANAGED_BINDING_MISMATCH' + | 'MANAGED_START_FAILED' + | 'MANAGED_STARTED_RECEIPT_INVALID' + | 'MANAGED_OUTCOME_FAILED' + | 'MANAGED_OUTCOME_RECEIPT_INVALID' + | 'MANAGED_DEADLINE_EXCEEDED' + | 'MANAGED_CANCEL_FAILED' + | 'MANAGED_CANCEL_RECEIPT_INVALID' + | 'MANAGED_CLOSE_FAILED' + | 'MANAGED_CLEANUP_RECEIPT_INVALID' + | 'MANAGED_SANDBOX_UNSUPPORTED' + | 'MANAGED_DEBOUNCE_UNSUPPORTED' + | 'MANAGED_FATAL_SUMMARY' + | 'MANAGED_FAIL_IF' + | 'MANAGED_HALT_EXECUTION' + | 'MANAGED_CLAIM_VALIDATION_FAILED' + | 'MANAGED_POST_PROVIDER_FAILED'; + +export type ManagedRunAcquisitionFailureCode = + | 'MANAGED_HANDLE_INVALID' + | 'MANAGED_BINDING_MISMATCH' + | 'MANAGED_START_FAILED' + | 'MANAGED_SANDBOX_UNSUPPORTED' + | 'MANAGED_DEBOUNCE_UNSUPPORTED'; + +export function deriveManagedRunId( + input: Omit +): string { + return sha256Canonical({ + v: 1, + type: 'managed-run', + sessionId: input.sessionId, + checkId: input.checkId, + scope: validateTaggedScopePath(input.scope), + nodeInstanceId: input.nodeInstanceId, + nodeGenerationId: input.nodeGenerationId, + attemptId: input.attemptId, + fence: input.fence, + }); +} + +interface InstanceEventBase { + readonly version: 1; + readonly eventId: number; + readonly sessionId: string; + readonly scope: TaggedScopePath; +} + +export interface CatalogReconciliationRequestedEvent extends InstanceEventBase { + readonly type: 'CatalogReconciliationRequested'; + readonly scope: RootScopePath; + readonly requestId: string; + readonly requestOrdinal: number; + readonly expansionOwnerCheck: string; + readonly status: 'pending'; +} + +export interface SubgraphExpandedEvent extends InstanceEventBase { + readonly type: 'SubgraphExpanded'; + readonly scope: KeyedScopePath; + readonly expansionOwnerCheck: string; + readonly graphSemanticDigest: string; + readonly expansionSpecDigest: string; + readonly templateDigest: string; + readonly parentSubgraphInstanceId: string | null; + readonly expansionOwnerNodeInstanceId?: string; + readonly catalogClaimRef?: string; + readonly catalogClaimId: string; + readonly itemKey: string; + readonly subgraphInstanceId: string; + readonly nodeInstanceIdsByTemplateNode: Readonly>; +} + +export interface ControllerItemClaimPublishedEvent extends InstanceEventBase { + readonly type: 'ControllerItemClaimPublished'; + readonly scope: KeyedScopePath; + readonly expansionOwnerCheck: string; + readonly expansionSpecDigest: string; + readonly catalogClaimId: string; + readonly itemKey: string; + readonly subgraphInstanceId: string; + readonly incarnation: number; + readonly claimId: string; + readonly claim: string; + readonly payload: unknown; + readonly payloadFingerprint: string; + readonly parentClaimIds: readonly [string]; +} + +export interface NodeGenerationInactivatedEvent extends InstanceEventBase { + readonly type: 'NodeGenerationInactivated'; + readonly scope: KeyedScopePath; + readonly subgraphInstanceId: string; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; + readonly incarnation: number; + readonly outputClaimIds: readonly string[]; + readonly reason: 'superseded'; +} + +export interface NodeGenerationActivatedEvent extends InstanceEventBase { + readonly type: 'NodeGenerationActivated'; + readonly scope: KeyedScopePath; + readonly subgraphInstanceId: string; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; + readonly templateNodeKey: string; + readonly checkId: string; + readonly incarnation: number; + readonly itemFingerprint: string; + readonly executionConfigDigest: string; + readonly activeInputClaimIds: readonly string[]; + readonly nestedExpansionCatalogClaimRef?: string; +} + +export interface SubgraphTombstonedEvent extends InstanceEventBase { + readonly type: 'SubgraphTombstoned'; + readonly scope: KeyedScopePath; + readonly expansionOwnerCheck: string; + readonly sourceCatalogClaimId: string; + readonly itemKey: string; + readonly subgraphInstanceId: string; + readonly lastIncarnation: number; + readonly nodeGenerationIds: readonly string[]; + readonly outputClaimIds: readonly string[]; +} + +interface BoundAttemptEventBase extends InstanceEventBase { + readonly checkId: string; + readonly attemptId: string; + readonly fence: number; +} + +export interface GeneratedAttemptStartedEvent extends BoundAttemptEventBase { + readonly type: 'AttemptStarted'; + readonly scope: KeyedScopePath; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; +} + +export interface GeneratedCheckScheduledEvent extends BoundAttemptEventBase { + readonly type: 'CheckScheduled'; + readonly scope: KeyedScopePath; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; + readonly claimIds: readonly string[]; +} + +export interface GeneratedClaimPublishedEvent extends BoundAttemptEventBase { + readonly type: 'ClaimPublished'; + readonly scope: KeyedScopePath; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; + readonly claimId: string; + readonly claim: string; + readonly payload: unknown; + readonly payloadFingerprint: string; + readonly producerCheckId: string; + readonly parentClaimIds: readonly string[]; +} + +export interface GeneratedAttemptCompletedEvent extends BoundAttemptEventBase { + readonly type: 'AttemptCompleted'; + readonly scope: KeyedScopePath; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; +} + +export interface GeneratedAttemptFailedEvent extends BoundAttemptEventBase { + readonly type: 'AttemptFailed'; + readonly scope: KeyedScopePath; + readonly nodeInstanceId: string; + readonly nodeGenerationId: string; + readonly reason: string; +} + +interface ManagedRunEventBase extends InstanceEventBase { + readonly scope: KeyedScopePath; + readonly binding: ManagedRunBindingV1; +} + +export interface ManagedRunAcquisitionFailedEvent extends ManagedRunEventBase { + readonly type: 'ManagedRunAcquisitionFailed'; + readonly failureCode: ManagedRunAcquisitionFailureCode; +} + +export interface ManagedRunAcquiredEvent extends ManagedRunEventBase { + readonly type: 'ManagedRunAcquired'; +} + +export interface ManagedRunStartedEvent extends ManagedRunEventBase { + readonly type: 'ManagedRunStarted'; +} + +export interface ManagedRunCancelRequestedEvent extends ManagedRunEventBase { + readonly type: 'ManagedRunCancelRequested'; + readonly reason: 'deadline'; +} + +export interface ManagedRunTerminatedEvent extends ManagedRunEventBase { + readonly type: 'ManagedRunTerminated'; + readonly cleanupStatus: ManagedRunCleanupStatus; + readonly controllerDecision: ManagedRunControllerDecision; + readonly failureCode: ManagedRunFailureCode | null; +} + +type ManagedRunLifecycleEvent = + | ManagedRunAcquisitionFailedEvent + | ManagedRunAcquiredEvent + | ManagedRunStartedEvent + | ManagedRunCancelRequestedEvent + | ManagedRunTerminatedEvent; + +export interface CatalogRequestAttemptStartedEvent extends BoundAttemptEventBase { + readonly type: 'AttemptStarted'; + readonly scope: RootScopePath; + readonly requestId: string; +} + +export interface CatalogRequestCheckScheduledEvent extends BoundAttemptEventBase { + readonly type: 'CheckScheduled'; + readonly scope: RootScopePath; + readonly requestId: string; + readonly claimIds: readonly string[]; +} + +export interface CatalogRequestAttemptCompletedEvent extends BoundAttemptEventBase { + readonly type: 'AttemptCompleted'; + readonly scope: RootScopePath; + readonly requestId: string; + readonly catalogClaimId: string; +} + +export interface CatalogRequestAttemptFailedEvent extends BoundAttemptEventBase { + readonly type: 'AttemptFailed'; + readonly scope: RootScopePath; + readonly requestId: string; + readonly reason: string; +} + +export type InstanceRuntimeEvent = + | CatalogReconciliationRequestedEvent + | SubgraphExpandedEvent + | ControllerItemClaimPublishedEvent + | NodeGenerationInactivatedEvent + | NodeGenerationActivatedEvent + | SubgraphTombstonedEvent + | GeneratedAttemptStartedEvent + | GeneratedCheckScheduledEvent + | GeneratedClaimPublishedEvent + | GeneratedAttemptCompletedEvent + | GeneratedAttemptFailedEvent + | ManagedRunAcquisitionFailedEvent + | ManagedRunAcquiredEvent + | ManagedRunStartedEvent + | ManagedRunCancelRequestedEvent + | ManagedRunTerminatedEvent + | CatalogRequestAttemptStartedEvent + | CatalogRequestCheckScheduledEvent + | CatalogRequestAttemptCompletedEvent + | CatalogRequestAttemptFailedEvent; + +export interface CatalogSelectedItem { + readonly key: string; + readonly itemFingerprint: string; +} + +export interface CatalogRequestProjection { + readonly requestId: string; + readonly requestOrdinal: number; + readonly sessionId: string; + readonly expansionOwnerCheck: string; + readonly status: CatalogRequestStatus; + readonly catalogClaimId?: string; + readonly attemptId?: string; + readonly fence?: number; + readonly reason?: string; +} + +export interface SubgraphInstanceProjection { + readonly sessionId: string; + readonly expansionOwnerCheck: string; + readonly graphSemanticDigest: string; + readonly expansionSpecDigest: string; + readonly templateDigest: string; + readonly itemKey: string; + readonly subgraphInstanceId: string; + readonly scope: KeyedScopePath; + readonly catalogClaimId: string; + readonly parentSubgraphInstanceId?: string; + readonly expansionOwnerNodeInstanceId?: string; + readonly catalogClaimRef?: string; + readonly catalogProducerNodeGenerationId?: string; + readonly nodeInstanceIdsByTemplateNode: Readonly>; + readonly status: 'active' | 'tombstoned'; + readonly incarnation: number; + readonly activeItemClaimId?: string; + readonly tombstoneCatalogClaimId?: string; +} + +export interface NodeInstanceProjection { + readonly nodeInstanceId: string; + readonly subgraphInstanceId: string; + readonly templateNodeKey: string; + readonly scope: KeyedScopePath; +} + +export interface NodeGenerationProjection { + readonly nodeGenerationId: string; + readonly nodeInstanceId: string; + readonly subgraphInstanceId: string; + readonly templateNodeKey: string; + readonly checkId: string; + readonly scope: KeyedScopePath; + readonly incarnation: number; + readonly itemFingerprint: string; + readonly executionConfigDigest: string; + readonly activeInputClaimIds: readonly string[]; + readonly nestedExpansionCatalogClaimRef?: string; + readonly status: NodeGenerationStatus; + readonly attemptId?: string; + readonly fence?: number; + readonly scheduled: boolean; + readonly completedOutputClaimIds: readonly string[]; + readonly reason?: string; +} + +export interface InstanceClaimProjection { + readonly claimId: string; + readonly claim: string; + readonly payload: unknown; + readonly payloadFingerprint: string; + readonly producerCheckId: string; + readonly producerAttemptId?: string; + readonly producerFence?: number; + readonly controllerCatalogClaimId?: string; + readonly parentClaimIds: readonly string[]; + readonly scope: KeyedScopePath; + readonly active: boolean; + readonly kind: 'controller-item' | 'generated-output'; + readonly subgraphInstanceId: string; + readonly incarnation: number; + readonly nodeGenerationId?: string; +} + +export interface ManagedRunProjection { + readonly binding: ManagedRunBindingV1; + readonly status: + | 'acquisition_failed' + | 'acquired' + | 'started' + | 'cancel_requested' + | 'terminated'; + readonly cleanupStatus?: ManagedRunCleanupStatus; + readonly controllerDecision?: ManagedRunControllerDecision; + readonly failureCode?: ManagedRunFailureCode; + readonly cancellationRequested?: true; +} + +export interface InstanceProjection { + readonly lastEventId: number; + readonly requestsById: Readonly>; + readonly requestOrder: readonly string[]; + readonly instancesById: Readonly>; + readonly instanceIdByOwnerAndKey: Readonly>; + readonly nodesById: Readonly>; + readonly generationsById: Readonly>; + readonly activeGenerationIdByNode: Readonly>; + readonly claimsById: Readonly>; + readonly attemptBindingsById: Readonly>; + readonly managedRunsByAttemptId: Readonly>; +} + +export type ExpansionCoverageClass = + | 'completed_clean' + | 'completed_with_findings' + | 'error' + | 'guardrail_blocked' + | 'cancelled'; + +export interface ExpansionCoverageProjection { + readonly requestId: string; + readonly expansionOwnerCheck: string; + readonly denominator: readonly CatalogSelectedItem[]; + readonly items: readonly Readonly<{ + key: string; + itemFingerprint: string; + terminalClass: ExpansionCoverageClass | null; + outcomeClaimId: string | null; + outcomePayloadFingerprint: string | null; + }>[]; + readonly closure: 'open' | 'closed'; + readonly disposition: 'clean' | 'findings' | 'unverifiable'; + readonly terminalItems: number; + readonly diagnostics: readonly string[]; + readonly semanticDigest: string; + readonly provenance: Readonly<{ catalogClaimId?: string; lastEventId: number }>; +} + +const PROVIDER_COVERAGE_CLASSES: ReadonlySet = new Set([ + 'completed_clean', 'completed_with_findings', 'guardrail_blocked', +]); + +/** Derive one request-bound coverage read model from immutable C2 facts. */ +export function projectExpansionCoverage( + claimProjection: ClaimProjection, + projection: InstanceProjection, + expansion: CompiledExpansion, + requestId: string +): ExpansionCoverageProjection { + const request = projection.requestsById[requestId]; + if (!request || request.expansionOwnerCheck !== expansion.expansionOwnerCheck) { + throw new InstanceKernelError('UNKNOWN_COVERAGE_REQUEST', `Unknown coverage request ${requestId}`); + } + if (!expansion.coverage) { + throw new InstanceKernelError('COVERAGE_NOT_CONFIGURED', `Expansion ${expansion.expansionOwnerCheck} has no coverage contract`); + } + const diagnostics: string[] = []; + const denominator: CatalogSelectedItem[] = []; + const catalog = request.catalogClaimId ? claimProjection.claims[request.catalogClaimId] : undefined; + const attempt = claimProjection.attempts[ + attemptProjectionKey(request.sessionId, request.expansionOwnerCheck, []) + ]; + const exactCatalog = catalog && + catalog.claimId === request.catalogClaimId && + catalog.claim === expansion.catalogClaimRef && + catalog.producerCheckId === request.expansionOwnerCheck && + catalog.attemptId === request.attemptId && + catalog.fence === request.fence && + catalog.scope.length === 0 && + attempt?.sessionId === request.sessionId && + attempt.checkId === request.expansionOwnerCheck && + attempt.scope.length === 0 && + attempt.attemptId === request.attemptId && + attempt.fence === request.fence && + attempt.status === 'completed' && + request.status === 'completed'; + if (exactCatalog) { + try { + expansion.catalogValidator(catalog.payload); + const rawItems = resolveJsonPointer(catalog.payload, expansion.itemsPointer); + if (!Array.isArray(rawItems)) throw new Error('items-not-array'); + const seen = new Set(); + for (const item of rawItems) { + expansion.itemValidator(item); + const key = canonicalCatalogKey(resolveJsonPointer(item, expansion.keyPointer)); + if (seen.has(key)) throw new Error('duplicate-key'); + seen.add(key); + denominator.push({ key, itemFingerprint: deriveItemFingerprint(item) }); + } + denominator.sort((left, right) => left.key.localeCompare(right.key)); + } catch { + diagnostics.push('catalog:invalid'); + } + } else diagnostics.push('catalog:lineage'); + const observedClaimIds = new Set(); + const selectedKeys = new Set(denominator.map(item => item.key)); + const items = denominator.map(selected => { + const instances = Object.values(projection.instancesById).filter(instance => + instance.sessionId === request.sessionId && + instance.expansionOwnerCheck === request.expansionOwnerCheck && + !instance.parentSubgraphInstanceId && + instance.itemKey === selected.key + ); + let terminalClass: ExpansionCoverageClass | null = null; + let outcomeClaimId: string | null = null; + let outcomePayloadFingerprint: string | null = null; + const instance = instances.length === 1 ? instances[0] : undefined; + const itemClaim = instance?.activeItemClaimId + ? projection.claimsById[instance.activeItemClaimId] + : undefined; + if (!instance || instance.status !== 'active' || !itemClaim?.active || + itemClaim.payloadFingerprint !== selected.itemFingerprint) { + diagnostics.push(`${selected.key}:lineage`); + return { ...selected, terminalClass, outcomeClaimId, outcomePayloadFingerprint }; + } + const emitterNodeId = instance.nodeInstanceIdsByTemplateNode[expansion.coverage!.emitterNodeKey]; + const generationId = emitterNodeId ? projection.activeGenerationIdByNode[emitterNodeId] : undefined; + const generation = generationId ? projection.generationsById[generationId] : undefined; + if (!generation || generation.itemFingerprint !== selected.itemFingerprint) { + diagnostics.push(`${selected.key}:lineage`); + return { ...selected, terminalClass, outcomeClaimId, outcomePayloadFingerprint }; + } + const outcomes = generation.completedOutputClaimIds + .map(claimId => projection.claimsById[claimId]) + .filter(claim => claim?.active && claim.claim === expansion.coverage!.outcomeClaimRef); + outcomes.forEach(claim => observedClaimIds.add(claim.claimId)); + const managed = generation.attemptId ? projection.managedRunsByAttemptId[generation.attemptId] : undefined; + const cancelled = generation.status === 'failed' && + generation.reason === 'MANAGED_DEADLINE_EXCEEDED' && + managed?.status === 'terminated' && + managed.cleanupStatus === 'clean' && + managed.controllerDecision === 'failed' && + managed.failureCode === 'MANAGED_DEADLINE_EXCEEDED' && + managed.cancellationRequested === true && + managed.binding.sessionId === request.sessionId && + managed.binding.checkId === generation.checkId && + managed.binding.nodeInstanceId === generation.nodeInstanceId && + managed.binding.nodeGenerationId === generation.nodeGenerationId && + managed.binding.attemptId === generation.attemptId && + managed.binding.fence === generation.fence && + scopePathEquals(managed.binding.scope, generation.scope); + if (outcomes.length === 1 && generation.status === 'completed') { + try { + const value = resolveJsonPointer(outcomes[0].payload, expansion.coverage!.classPointer); + if (typeof value === 'string' && PROVIDER_COVERAGE_CLASSES.has(value)) { + terminalClass = value as ExpansionCoverageClass; + outcomeClaimId = outcomes[0].claimId; + outcomePayloadFingerprint = outcomes[0].payloadFingerprint; + } else diagnostics.push(`${selected.key}:invalid-class`); + } catch { + diagnostics.push(`${selected.key}:invalid-class`); + } + } else if (outcomes.length === 0 && cancelled) terminalClass = 'cancelled'; + else if (outcomes.length === 0 && generation.status === 'failed') terminalClass = 'error'; + else if (outcomes.length > 1 || (outcomes.length > 0 && generation.status !== 'completed')) { + diagnostics.push(`${selected.key}:conflicting-terminal`); + } else diagnostics.push(`${selected.key}:nonterminal`); + return { ...selected, terminalClass, outcomeClaimId, outcomePayloadFingerprint }; + }); + for (const instance of Object.values(projection.instancesById)) { + if (instance.sessionId !== request.sessionId || + instance.expansionOwnerCheck !== request.expansionOwnerCheck || + instance.parentSubgraphInstanceId || instance.status !== 'active') continue; + if (!selectedKeys.has(instance.itemKey)) diagnostics.push(`${instance.itemKey}:unknown`); + } + for (const claim of Object.values(projection.claimsById)) { + if (!claim.active || claim.claim !== expansion.coverage.outcomeClaimRef) continue; + const instance = projection.instancesById[claim.subgraphInstanceId]; + if (instance?.sessionId === request.sessionId && + instance.expansionOwnerCheck === request.expansionOwnerCheck && + !instance.parentSubgraphInstanceId && !observedClaimIds.has(claim.claimId)) { + diagnostics.push(`${instance.itemKey}:unknown-terminal`); + } + } + const canonicalDiagnostics = [...new Set(diagnostics)].sort(); + const terminalItems = items.filter(item => item.terminalClass !== null).length; + const closure = canonicalDiagnostics.length === 0 && terminalItems === items.length ? 'closed' as const : 'open' as const; + const classes = items.map(item => item.terminalClass); + const disposition = closure === 'open' || classes.some(value => + value === 'error' || value === 'guardrail_blocked' || value === 'cancelled' + ) ? 'unverifiable' as const : classes.some(value => value === 'completed_with_findings') + ? 'findings' as const : 'clean' as const; + const semantic = { expansionOwnerCheck: request.expansionOwnerCheck, denominator, + outcomes: items.map(item => ({ key: item.key, terminalClass: item.terminalClass, + outcomePayloadFingerprint: item.outcomePayloadFingerprint })), + closure, disposition, terminalItems, diagnostics: canonicalDiagnostics }; + return immutableCanonicalValue({ requestId, expansionOwnerCheck: request.expansionOwnerCheck, + denominator, items, closure, disposition, terminalItems, diagnostics: canonicalDiagnostics, + semanticDigest: sha256Canonical({ v: 2, ...semantic }), + provenance: { ...(request.catalogClaimId ? { catalogClaimId: request.catalogClaimId } : {}), + lastEventId: projection.lastEventId } }); +} + +export function createInitialInstanceProjection(): InstanceProjection { + return immutableCanonicalValue({ + lastEventId: 0, + requestsById: {}, + requestOrder: [], + instancesById: {}, + instanceIdByOwnerAndKey: {}, + nodesById: {}, + generationsById: {}, + activeGenerationIdByNode: {}, + claimsById: {}, + attemptBindingsById: {}, + managedRunsByAttemptId: {}, + }); +} + +export function immutableInstanceEvent(event: T): T { + return immutableCanonicalValue(event); +} + +function ownerKey( + expansionOwnerCheck: string, + itemKey: string, + parentSubgraphInstanceId: string | null = null, + expansionOwnerNodeInstanceId?: string +): string { + return parentSubgraphInstanceId === null + ? canonicalJson([expansionOwnerCheck, itemKey]) + : canonicalJson([ + expansionOwnerCheck, + parentSubgraphInstanceId, + expansionOwnerNodeInstanceId, + itemKey, + ]); +} + +function sortedUnique(values: readonly string[], label: string): readonly string[] { + if (values.some(value => typeof value !== 'string' || value.length === 0)) { + throw new InstanceKernelError('INVALID_EVENT', `${label} must contain non-empty strings`); + } + const sorted = [...values].sort(); + if (new Set(sorted).size !== sorted.length) { + throw new InstanceKernelError('INVALID_EVENT', `${label} must not contain duplicates`); + } + if (values.some((value, index) => value !== sorted[index])) { + throw new InstanceKernelError('INVALID_EVENT', `${label} must be canonically sorted`); + } + return sorted; +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function mutableProjection(projection: InstanceProjection): { + lastEventId: number; + requestsById: Record; + requestOrder: string[]; + instancesById: Record; + instanceIdByOwnerAndKey: Record; + nodesById: Record; + generationsById: Record; + activeGenerationIdByNode: Record; + claimsById: Record; + attemptBindingsById: Record; + managedRunsByAttemptId: Record; +} { + return { + lastEventId: projection.lastEventId, + requestsById: { ...projection.requestsById }, + requestOrder: [...projection.requestOrder], + instancesById: { ...projection.instancesById }, + instanceIdByOwnerAndKey: { ...projection.instanceIdByOwnerAndKey }, + nodesById: { ...projection.nodesById }, + generationsById: { ...projection.generationsById }, + activeGenerationIdByNode: { ...projection.activeGenerationIdByNode }, + claimsById: { ...projection.claimsById }, + attemptBindingsById: { ...projection.attemptBindingsById }, + managedRunsByAttemptId: { ...projection.managedRunsByAttemptId }, + }; +} + +function requireInstance( + projection: InstanceProjection, + subgraphInstanceId: string, + scope: unknown, + allowNestedTombstone = false +): SubgraphInstanceProjection { + const instance = projection.instancesById[subgraphInstanceId]; + if ( + !instance || + (instance.status !== 'active' && !(allowNestedTombstone && instance.parentSubgraphInstanceId)) + ) { + throw new InstanceKernelError('INACTIVE_INSTANCE', `Instance ${subgraphInstanceId} is not active`); + } + const exactScope = requireKeyedScopePath(scope, instance.scope); + const segment = exactScope[exactScope.length - 1]; + if ( + segment.expansionOwnerCheck !== instance.expansionOwnerCheck || + segment.key !== instance.itemKey || + segment.subgraphInstanceId !== instance.subgraphInstanceId + ) { + throw new InstanceKernelError('INVALID_SCOPE', 'Instance scope leaf is not exact'); + } + if (instance.parentSubgraphInstanceId) { + if (exactScope.length !== 2 || !instance.expansionOwnerNodeInstanceId) { + throw new InstanceKernelError('INVALID_SCOPE', 'Nested instance lacks its exact parent chain'); + } + const parent = projection.instancesById[instance.parentSubgraphInstanceId]; + const ownerNode = projection.nodesById[instance.expansionOwnerNodeInstanceId]; + if ( + !parent || + parent.status !== 'active' || + parent.scope.length !== 1 || + !scopePathEquals([exactScope[0]], parent.scope) || + !ownerNode || + ownerNode.subgraphInstanceId !== parent.subgraphInstanceId || + !scopePathEquals(ownerNode.scope, parent.scope) + ) { + throw new InstanceKernelError('INVALID_SCOPE', 'Nested instance ancestor chain is invalid'); + } + } else if (exactScope.length !== 1) { + throw new InstanceKernelError('INVALID_SCOPE', 'Root-expanded instance scope must have one segment'); + } + return instance; +} + +function requireNestedCatalogAuthority( + projection: InstanceProjection, + input: { + readonly parentSubgraphInstanceId: string; + readonly expansionOwnerNodeInstanceId: string; + readonly catalogClaimId: string; + } +): NodeGenerationProjection { + const parent = requireInstance( + projection, + input.parentSubgraphInstanceId, + projection.instancesById[input.parentSubgraphInstanceId]?.scope + ); + const ownerNode = projection.nodesById[input.expansionOwnerNodeInstanceId]; + const catalog = projection.claimsById[input.catalogClaimId]; + const generation = catalog?.nodeGenerationId + ? projection.generationsById[catalog.nodeGenerationId] + : undefined; + if ( + !ownerNode || + ownerNode.subgraphInstanceId !== parent.subgraphInstanceId || + !generation || + !generation.nestedExpansionCatalogClaimRef || + !catalog.active || + catalog.kind !== 'generated-output' || + catalog.claim !== generation.nestedExpansionCatalogClaimRef || + catalog.subgraphInstanceId !== parent.subgraphInstanceId || + catalog.nodeGenerationId !== generation.nodeGenerationId || + generation.nodeInstanceId !== ownerNode.nodeInstanceId || + generation.subgraphInstanceId !== parent.subgraphInstanceId || + generation.status !== 'running' || + !generation.scheduled || + projection.activeGenerationIdByNode[ownerNode.nodeInstanceId] !== generation.nodeGenerationId || + catalog.producerAttemptId !== generation.attemptId || + catalog.producerFence !== generation.fence || + catalog.producerCheckId !== generation.checkId || + !scopePathEquals(catalog.scope, parent.scope) + ) { + throw new InstanceKernelError( + 'INVALID_NESTED_CATALOG_LINEAGE', + 'Nested catalog is not the exact active output of its current fenced owner generation' + ); + } + return generation; +} + +function requireGeneration( + projection: InstanceProjection, + event: { readonly nodeInstanceId: string; readonly nodeGenerationId: string; readonly scope: unknown } +): NodeGenerationProjection { + const generation = projection.generationsById[event.nodeGenerationId]; + if ( + !generation || + generation.nodeInstanceId !== event.nodeInstanceId || + !scopePathEquals(generation.scope, event.scope) + ) { + throw new InstanceKernelError( + 'INVALID_GENERATION_BINDING', + `Generation ${event.nodeGenerationId} is not bound to the supplied node and scope` + ); + } + if (projection.activeGenerationIdByNode[event.nodeInstanceId] !== event.nodeGenerationId) { + throw new InstanceKernelError('STALE_GENERATION', `Generation ${event.nodeGenerationId} is inactive`); + } + return generation; +} + +function requireAttemptBinding( + projection: InstanceProjection, + event: BoundAttemptEventBase & { readonly nodeGenerationId?: string; readonly requestId?: string } +): string { + const binding = projection.attemptBindingsById[event.attemptId]; + const expected = event.nodeGenerationId || event.requestId; + if (!binding || binding !== expected) { + throw new InstanceKernelError('INVALID_ATTEMPT_BINDING', `Attempt ${event.attemptId} is misbound`); + } + return binding; +} + +const MANAGED_RUN_FAILURE_CODES: ReadonlySet = new Set([ + 'MANAGED_HANDLE_INVALID', + 'MANAGED_BINDING_MISMATCH', + 'MANAGED_START_FAILED', + 'MANAGED_STARTED_RECEIPT_INVALID', + 'MANAGED_OUTCOME_FAILED', + 'MANAGED_OUTCOME_RECEIPT_INVALID', + 'MANAGED_DEADLINE_EXCEEDED', + 'MANAGED_CANCEL_FAILED', + 'MANAGED_CANCEL_RECEIPT_INVALID', + 'MANAGED_CLOSE_FAILED', + 'MANAGED_CLEANUP_RECEIPT_INVALID', + 'MANAGED_SANDBOX_UNSUPPORTED', + 'MANAGED_DEBOUNCE_UNSUPPORTED', + 'MANAGED_FATAL_SUMMARY', + 'MANAGED_FAIL_IF', + 'MANAGED_HALT_EXECUTION', + 'MANAGED_CLAIM_VALIDATION_FAILED', + 'MANAGED_POST_PROVIDER_FAILED', +]); + +const MANAGED_RUN_ACQUISITION_FAILURE_CODES: ReadonlySet = new Set< + ManagedRunAcquisitionFailureCode +>([ + 'MANAGED_HANDLE_INVALID', + 'MANAGED_BINDING_MISMATCH', + 'MANAGED_START_FAILED', + 'MANAGED_SANDBOX_UNSUPPORTED', + 'MANAGED_DEBOUNCE_UNSUPPORTED', +]); + +const MANAGED_RUN_UNVERIFIED_CLEANUP_FAILURE_CODES: ReadonlySet = + new Set(['MANAGED_CLOSE_FAILED', 'MANAGED_CLEANUP_RECEIPT_INVALID']); + +const MANAGED_RUN_CANCEL_PATH_FAILURE_CODES: ReadonlySet = new Set([ + 'MANAGED_DEADLINE_EXCEEDED', + 'MANAGED_CANCEL_FAILED', + 'MANAGED_CANCEL_RECEIPT_INVALID', + 'MANAGED_CLOSE_FAILED', + 'MANAGED_CLEANUP_RECEIPT_INVALID', +]); + +const MANAGED_RUN_CANCEL_ONLY_FAILURE_CODES: ReadonlySet = + new Set([ + 'MANAGED_DEADLINE_EXCEEDED', + 'MANAGED_CANCEL_FAILED', + 'MANAGED_CANCEL_RECEIPT_INVALID', + ]); + +function managedBindingEquals(left: ManagedRunBindingV1, right: ManagedRunBindingV1): boolean { + return ( + left.managedRunId === right.managedRunId && + left.sessionId === right.sessionId && + left.checkId === right.checkId && + scopePathEquals(left.scope, right.scope) && + left.nodeInstanceId === right.nodeInstanceId && + left.nodeGenerationId === right.nodeGenerationId && + left.attemptId === right.attemptId && + left.fence === right.fence + ); +} + +function requireManagedRunBinding(value: unknown): ManagedRunBindingV1 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new InstanceKernelError('INVALID_MANAGED_BINDING', 'Managed run binding must be an object'); + } + const binding = value as unknown as Record; + if ( + !hasExactKeys(binding, [ + 'managedRunId', + 'sessionId', + 'checkId', + 'scope', + 'nodeInstanceId', + 'nodeGenerationId', + 'attemptId', + 'fence', + ]) + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Managed run binding must contain exactly eight authority fields' + ); + } + for (const field of [ + 'managedRunId', + 'sessionId', + 'checkId', + 'nodeInstanceId', + 'nodeGenerationId', + 'attemptId', + ] as const) { + if (typeof binding[field] !== 'string' || binding[field].length === 0) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + `Managed run binding ${field} must be a non-empty string` + ); + } + } + if (!Number.isSafeInteger(binding.fence) || (binding.fence as number) < 1) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Managed run binding fence must be a positive safe integer' + ); + } + const scope = requireKeyedScopePath(binding.scope); + const candidate: ManagedRunBindingV1 = { + managedRunId: binding.managedRunId as string, + sessionId: binding.sessionId as string, + checkId: binding.checkId as string, + scope, + nodeInstanceId: binding.nodeInstanceId as string, + nodeGenerationId: binding.nodeGenerationId as string, + attemptId: binding.attemptId as string, + fence: binding.fence as number, + }; + if (deriveManagedRunId(candidate) !== candidate.managedRunId) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Managed run ID is not derived from its exact controller binding' + ); + } + return candidate; +} + +function requireManagedEventShape(event: ManagedRunLifecycleEvent): void { + const common = ['version', 'type', 'eventId', 'sessionId', 'scope', 'binding']; + const expected = + event.type === 'ManagedRunAcquisitionFailed' + ? [...common, 'failureCode'] + : event.type === 'ManagedRunCancelRequested' + ? [...common, 'reason'] + : event.type === 'ManagedRunTerminated' + ? [...common, 'cleanupStatus', 'controllerDecision', 'failureCode'] + : common; + if (!hasExactKeys(event as unknown as Record, expected)) { + throw new InstanceKernelError('INVALID_MANAGED_EVENT', `${event.type} has unknown or missing fields`); + } +} + +function requireCurrentManagedBinding( + projection: InstanceProjection, + event: ManagedRunLifecycleEvent +): ManagedRunBindingV1 { + requireManagedEventShape(event); + const binding = requireManagedRunBinding(event.binding); + if (event.sessionId !== binding.sessionId || !scopePathEquals(event.scope, binding.scope)) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Managed lifecycle envelope does not match its complete binding' + ); + } + const generation = requireGeneration(projection, binding); + const instance = requireInstance(projection, generation.subgraphInstanceId, binding.scope); + if ( + projection.attemptBindingsById[binding.attemptId] !== binding.nodeGenerationId || + instance.sessionId !== binding.sessionId || + generation.checkId !== binding.checkId || + generation.nodeInstanceId !== binding.nodeInstanceId || + generation.nodeGenerationId !== binding.nodeGenerationId || + generation.status !== 'running' || + generation.attemptId !== binding.attemptId || + generation.fence !== binding.fence || + !generation.scheduled + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Managed run binding is not the exact current scheduled running attempt' + ); + } + return binding; +} + +function generatedAttemptMatchesManagedBinding( + event: GeneratedAttemptCompletedEvent | GeneratedAttemptFailedEvent, + binding: ManagedRunBindingV1 +): boolean { + return ( + event.sessionId === binding.sessionId && + event.checkId === binding.checkId && + scopePathEquals(event.scope, binding.scope) && + event.nodeInstanceId === binding.nodeInstanceId && + event.nodeGenerationId === binding.nodeGenerationId && + event.attemptId === binding.attemptId && + event.fence === binding.fence + ); +} + +function reduceManagedRunLifecycle( + projection: InstanceProjection, + next: ReturnType, + event: ManagedRunLifecycleEvent +): void { + const binding = requireCurrentManagedBinding(projection, event); + const current = projection.managedRunsByAttemptId[binding.attemptId]; + if (current && !managedBindingEquals(current.binding, binding)) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Managed attempt index resolved to a different complete binding' + ); + } + + if (event.type === 'ManagedRunAcquisitionFailed') { + if (current || !MANAGED_RUN_ACQUISITION_FAILURE_CODES.has(event.failureCode)) { + throw new InstanceKernelError( + current ? 'MANAGED_RUN_ALREADY_ACQUIRED' : 'INVALID_MANAGED_FAILURE_CODE', + 'Managed acquisition failure is duplicate or has an invalid stable code' + ); + } + next.managedRunsByAttemptId[binding.attemptId] = { + binding, + status: 'acquisition_failed', + controllerDecision: 'failed', + failureCode: event.failureCode, + }; + return; + } + + if (event.type === 'ManagedRunAcquired') { + if (current) { + throw new InstanceKernelError('MANAGED_RUN_ALREADY_ACQUIRED', 'Managed run was already acquired'); + } + next.managedRunsByAttemptId[binding.attemptId] = { binding, status: 'acquired' }; + return; + } + + if (!current || current.status === 'acquisition_failed' || current.status === 'terminated') { + throw new InstanceKernelError( + 'INVALID_MANAGED_TRANSITION', + 'Managed lifecycle event requires one matching nonterminal acquired run' + ); + } + + if (event.type === 'ManagedRunStarted') { + if (current.status !== 'acquired') { + throw new InstanceKernelError('INVALID_MANAGED_TRANSITION', 'Managed run start is duplicate or late'); + } + next.managedRunsByAttemptId[binding.attemptId] = { ...current, status: 'started' }; + return; + } + + if (event.type === 'ManagedRunCancelRequested') { + if (event.reason !== 'deadline' || current.status === 'cancel_requested') { + throw new InstanceKernelError( + 'INVALID_MANAGED_TRANSITION', + 'Managed cancellation must be one current-fence deadline request' + ); + } + next.managedRunsByAttemptId[binding.attemptId] = { + ...current, + status: 'cancel_requested', + cancellationRequested: true, + }; + return; + } + + const completed = event.controllerDecision === 'completed'; + const failed = event.controllerDecision === 'failed'; + const cleanupIsValid = event.cleanupStatus === 'clean' || event.cleanupStatus === 'unverified'; + const failureCodeIsValid = + event.failureCode !== null && MANAGED_RUN_FAILURE_CODES.has(event.failureCode); + const acquisitionCodeUsedAsTerminal = + event.failureCode !== null && MANAGED_RUN_ACQUISITION_FAILURE_CODES.has(event.failureCode); + const unverifiedCode = + event.failureCode !== null && + MANAGED_RUN_UNVERIFIED_CLEANUP_FAILURE_CODES.has(event.failureCode); + const cancelPathCode = + event.failureCode !== null && MANAGED_RUN_CANCEL_PATH_FAILURE_CODES.has(event.failureCode); + const cancelOnlyCode = + event.failureCode !== null && MANAGED_RUN_CANCEL_ONLY_FAILURE_CODES.has(event.failureCode); + const cancelWasRequested = current.status === 'cancel_requested'; + if ( + !cleanupIsValid || + (!completed && !failed) || + (completed && + (event.cleanupStatus !== 'clean' || + event.failureCode !== null || + cancelWasRequested)) || + (!completed && + (!failureCodeIsValid || + acquisitionCodeUsedAsTerminal || + (event.cleanupStatus === 'unverified') !== unverifiedCode || + (cancelWasRequested ? !cancelPathCode : cancelOnlyCode))) + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_TERMINAL', + 'Managed terminal cleanup, decision, and failure code are inconsistent' + ); + } + next.managedRunsByAttemptId[binding.attemptId] = { + ...current, + status: 'terminated', + cleanupStatus: event.cleanupStatus, + controllerDecision: event.controllerDecision, + ...(event.failureCode === null ? {} : { failureCode: event.failureCode }), + }; +} + +function hasReadyOrRunningGeneration(projection: InstanceProjection): boolean { + return Object.values(projection.generationsById).some( + generation => generation.status === 'ready' || generation.status === 'running' + ); +} + +function reduceRequestLifecycle( + projection: InstanceProjection, + next: ReturnType, + event: + | CatalogRequestAttemptStartedEvent + | CatalogRequestCheckScheduledEvent + | CatalogRequestAttemptCompletedEvent + | CatalogRequestAttemptFailedEvent +): void { + requireRootScopePath(event.scope); + const request = projection.requestsById[event.requestId]; + if (!request || request.expansionOwnerCheck !== event.checkId || request.sessionId !== event.sessionId) { + throw new InstanceKernelError('INVALID_REQUEST_BINDING', `Request ${event.requestId} is misbound`); + } + if (event.type === 'AttemptStarted') { + if (request.status !== 'pending') { + throw new InstanceKernelError('INVALID_REQUEST_STATE', `Request ${event.requestId} is not pending`); + } + const oldestNonterminal = projection.requestOrder.find( + requestId => { + const status = projection.requestsById[requestId].status; + return status === 'pending' || status === 'running'; + } + ); + const hasRunningRequest = Object.values(projection.requestsById).some( + candidate => candidate.status === 'running' + ); + if ( + oldestNonterminal !== event.requestId || + hasRunningRequest || + hasReadyOrRunningGeneration(projection) + ) { + throw new InstanceKernelError( + 'GENERATED_WORK_PRECEDES_REQUEST', + 'Catalog request cannot start before older requests or generated work' + ); + } + if (projection.attemptBindingsById[event.attemptId]) { + throw new InstanceKernelError('DUPLICATE_ATTEMPT', `Attempt ${event.attemptId} already exists`); + } + next.requestsById[event.requestId] = { + ...request, + status: 'running', + attemptId: event.attemptId, + fence: event.fence, + }; + next.attemptBindingsById[event.attemptId] = event.requestId; + return; + } + requireAttemptBinding(projection, event); + if ( + request.status !== 'running' || + request.attemptId !== event.attemptId || + request.fence !== event.fence + ) { + throw new InstanceKernelError('INVALID_REQUEST_STATE', `Request ${event.requestId} is not running`); + } + if (event.type === 'CheckScheduled') return; + if (event.type === 'AttemptCompleted') { + if (!SHA256_PATTERN.test(event.catalogClaimId)) { + throw new InstanceKernelError('INVALID_REQUEST_CATALOG', 'Completed request catalog claim ID is invalid'); + } + next.requestsById[event.requestId] = { ...request, status: 'completed', + catalogClaimId: event.catalogClaimId }; + return; + } + next.requestsById[event.requestId] = { + ...request, + status: 'failed', + reason: event.reason, + }; +} + +function reduceGeneratedLifecycle( + projection: InstanceProjection, + next: ReturnType, + event: + | GeneratedAttemptStartedEvent + | GeneratedCheckScheduledEvent + | GeneratedClaimPublishedEvent + | GeneratedAttemptCompletedEvent + | GeneratedAttemptFailedEvent +): void { + const generation = requireGeneration(projection, event); + const instance = requireInstance(projection, generation.subgraphInstanceId, event.scope); + if (event.checkId !== generation.checkId || instance.incarnation !== generation.incarnation) { + throw new InstanceKernelError('INVALID_GENERATION_BINDING', 'Generated event has stale check or incarnation'); + } + if (event.type === 'AttemptStarted') { + if (generation.status !== 'ready') { + throw new InstanceKernelError('GENERATION_NOT_READY', `Generation ${event.nodeGenerationId} is not ready`); + } + if (projection.attemptBindingsById[event.attemptId]) { + throw new InstanceKernelError('DUPLICATE_ATTEMPT', `Attempt ${event.attemptId} already exists`); + } + next.generationsById[event.nodeGenerationId] = { + ...generation, + status: 'running', + attemptId: event.attemptId, + fence: event.fence, + }; + next.attemptBindingsById[event.attemptId] = event.nodeGenerationId; + return; + } + + requireAttemptBinding(projection, event); + if ( + generation.status !== 'running' || + generation.attemptId !== event.attemptId || + generation.fence !== event.fence + ) { + throw new InstanceKernelError('STALE_FENCE', `Attempt ${event.attemptId} is not current`); + } + if (event.type === 'CheckScheduled') { + const claimIds = sortedUnique(event.claimIds, 'Scheduled claim IDs'); + if (!sameStrings(claimIds, generation.activeInputClaimIds)) { + throw new InstanceKernelError( + 'INVALID_SCHEDULED_CLAIMS', + 'Generated check was not scheduled with its exact activated inputs' + ); + } + next.generationsById[event.nodeGenerationId] = { ...generation, scheduled: true }; + return; + } + if (!generation.scheduled) { + throw new InstanceKernelError('ATTEMPT_NOT_SCHEDULED', `Attempt ${event.attemptId} is not scheduled`); + } + const managed = projection.managedRunsByAttemptId[event.attemptId]; + if (managed) { + if ( + !generatedAttemptMatchesManagedBinding( + event as GeneratedAttemptCompletedEvent | GeneratedAttemptFailedEvent, + managed.binding + ) + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BINDING', + 'Generated event does not match the complete managed run binding' + ); + } + if (event.type === 'ClaimPublished') { + if ( + managed.status !== 'terminated' || + managed.cleanupStatus !== 'clean' || + managed.controllerDecision !== 'completed' || + managed.failureCode !== undefined + ) { + throw new InstanceKernelError( + 'MANAGED_TERMINAL_REQUIRED', + 'Managed claims require a clean controller-completed terminal fact' + ); + } + } else if (event.type === 'AttemptCompleted') { + if ( + managed.status !== 'terminated' || + managed.cleanupStatus !== 'clean' || + managed.controllerDecision !== 'completed' || + managed.failureCode !== undefined + ) { + throw new InstanceKernelError( + 'MANAGED_TERMINAL_REQUIRED', + 'Managed completion requires a clean controller-completed terminal fact' + ); + } + } else if (event.type === 'AttemptFailed') { + if ( + (managed.status !== 'acquisition_failed' && managed.status !== 'terminated') || + managed.controllerDecision !== 'failed' || + managed.failureCode === undefined || + event.reason !== managed.failureCode + ) { + throw new InstanceKernelError( + 'MANAGED_TERMINAL_REQUIRED', + 'Managed failure requires its controller-failed lifecycle terminal and stable code' + ); + } + } + } + if (event.type === 'ClaimPublished') { + if (event.producerCheckId !== generation.checkId) { + throw new InstanceKernelError('INVALID_GENERATION_BINDING', 'Generated claim has wrong producer'); + } + if (!sameStrings(event.parentClaimIds, generation.activeInputClaimIds)) { + throw new InstanceKernelError('INVALID_PARENT_CLAIMS', 'Generated claim has wrong exact parents'); + } + const payloadFingerprint = sha256Canonical(event.payload); + if (payloadFingerprint !== event.payloadFingerprint) { + throw new InstanceKernelError('INVALID_PAYLOAD_FINGERPRINT', 'Generated claim fingerprint is invalid'); + } + const claimId = sha256Canonical({ + claim: event.claim, + payloadFingerprint, + producerCheckId: event.checkId, + scope: event.scope, + attemptId: event.attemptId, + fence: event.fence, + parentClaimIds: [...event.parentClaimIds].sort(), + }); + if (claimId !== event.claimId || projection.claimsById[event.claimId]) { + throw new InstanceKernelError('INVALID_CLAIM_ID', 'Generated claim ID is invalid or duplicate'); + } + next.claimsById[event.claimId] = { + claimId: event.claimId, + claim: event.claim, + payload: immutableCanonicalValue(event.payload), + payloadFingerprint, + producerCheckId: event.producerCheckId, + producerAttemptId: event.attemptId, + producerFence: event.fence, + parentClaimIds: [...event.parentClaimIds], + scope: requireKeyedScopePath(event.scope, instance.scope), + active: true, + kind: 'generated-output', + subgraphInstanceId: instance.subgraphInstanceId, + incarnation: generation.incarnation, + nodeGenerationId: generation.nodeGenerationId, + }; + next.generationsById[event.nodeGenerationId] = { + ...generation, + completedOutputClaimIds: [...generation.completedOutputClaimIds, event.claimId], + }; + return; + } + if (event.type === 'AttemptFailed' && generation.completedOutputClaimIds.length > 0) { + throw new InstanceKernelError('PARTIAL_CLAIM_PUBLICATION', 'Failed generation has published claims'); + } + next.generationsById[event.nodeGenerationId] = { + ...generation, + status: event.type === 'AttemptCompleted' ? 'completed' : 'failed', + ...(event.type === 'AttemptFailed' ? { reason: event.reason } : {}), + }; +} + +/** Pure C2 transition reducer. The returned projection is a deep immutable value. */ +export function reduceInstanceEvent( + projection: InstanceProjection, + event: InstanceRuntimeEvent +): InstanceProjection { + if (event.version !== 1) { + throw new InstanceKernelError('UNSUPPORTED_EVENT_VERSION', 'Unsupported event version'); + } + if (!Number.isSafeInteger(event.eventId) || event.eventId <= projection.lastEventId) { + throw new InstanceKernelError('NON_MONOTONIC_EVENT', 'C2 event IDs must advance monotonically'); + } + validateTaggedScopePath(event.scope); + const next = mutableProjection(projection); + next.lastEventId = event.eventId; + + switch (event.type) { + case 'CatalogReconciliationRequested': { + requireRootScopePath(event.scope); + if ( + event.status !== 'pending' || + !Number.isSafeInteger(event.requestOrdinal) || + event.requestOrdinal < 1 || + event.requestId !== + deriveCatalogRequestId({ + sessionId: event.sessionId, + expansionOwnerCheck: event.expansionOwnerCheck, + ordinal: event.requestOrdinal, + }) + ) { + throw new InstanceKernelError('INVALID_REQUEST_ID', 'Catalog request identity is invalid'); + } + if (projection.requestsById[event.requestId]) { + throw new InstanceKernelError('DUPLICATE_REQUEST', `Request ${event.requestId} already exists`); + } + next.requestsById[event.requestId] = { + requestId: event.requestId, + requestOrdinal: event.requestOrdinal, + sessionId: event.sessionId, + expansionOwnerCheck: event.expansionOwnerCheck, + status: 'pending', + }; + next.requestOrder.push(event.requestId); + break; + } + case 'SubgraphExpanded': { + const itemKey = canonicalCatalogKey(event.itemKey); + const commonKeys = [ + 'version', 'type', 'eventId', 'sessionId', 'scope', 'expansionOwnerCheck', + 'graphSemanticDigest', 'expansionSpecDigest', 'templateDigest', + 'parentSubgraphInstanceId', 'catalogClaimId', 'itemKey', 'subgraphInstanceId', + 'nodeInstanceIdsByTemplateNode', + ]; + const nested = event.parentSubgraphInstanceId !== null; + if (!hasExactKeys( + event as unknown as Record, + nested + ? [...commonKeys, 'expansionOwnerNodeInstanceId', 'catalogClaimRef'] + : commonKeys + )) { + throw new InstanceKernelError('INVALID_EXPANSION', 'Expanded subgraph event shape is invalid'); + } + let catalogProducer: NodeGenerationProjection | undefined; + let expectedId: string; + let expectedScope: KeyedScopePath; + if (nested) { + if (!event.expansionOwnerNodeInstanceId) { + throw new InstanceKernelError('INVALID_INSTANCE_ID', 'Nested owner node is required'); + } + requireNonEmpty(event.catalogClaimRef, 'Nested catalog claim reference'); + const parent = requireInstance( + projection, + event.parentSubgraphInstanceId as string, + projection.instancesById[event.parentSubgraphInstanceId as string]?.scope + ); + if (parent.sessionId !== event.sessionId) { + throw new InstanceKernelError('INVALID_INSTANCE_ID', 'Nested parent session is invalid'); + } + catalogProducer = requireNestedCatalogAuthority(projection, { + parentSubgraphInstanceId: parent.subgraphInstanceId, + expansionOwnerNodeInstanceId: event.expansionOwnerNodeInstanceId, + catalogClaimId: event.catalogClaimId, + }); + if ( + event.catalogClaimRef !== catalogProducer.nestedExpansionCatalogClaimRef + ) { + throw new InstanceKernelError( + 'INVALID_NESTED_CATALOG_LINEAGE', + 'Nested child catalog declaration does not match its parent generation authority' + ); + } + expectedId = deriveSubgraphInstanceId({ + graphSemanticDigest: event.graphSemanticDigest, + parentSubgraphInstanceId: parent.subgraphInstanceId, + expansionOwnerNodeInstanceId: event.expansionOwnerNodeInstanceId, + templateDigest: event.templateDigest, + itemKey, + }); + expectedScope = Object.freeze([ + ...parent.scope, + { + kind: 'keyed' as const, + expansionOwnerCheck: event.expansionOwnerCheck, + key: itemKey, + subgraphInstanceId: expectedId, + }, + ]) as KeyedScopePath; + } else { + expectedId = deriveSubgraphInstanceId({ + graphSemanticDigest: event.graphSemanticDigest, + expansionOwnerCheck: event.expansionOwnerCheck, + parentSubgraphInstanceId: null, + templateDigest: event.templateDigest, + itemKey, + }); + expectedScope = Object.freeze([{ + kind: 'keyed' as const, + expansionOwnerCheck: event.expansionOwnerCheck, + key: itemKey, + subgraphInstanceId: expectedId, + }]); + } + const scope = requireKeyedScopePath(event.scope, expectedScope); + if (event.subgraphInstanceId !== expectedId) { + throw new InstanceKernelError('INVALID_INSTANCE_ID', 'Subgraph instance identity is invalid'); + } + const indexKey = ownerKey( + event.expansionOwnerCheck, + itemKey, + event.parentSubgraphInstanceId, + event.expansionOwnerNodeInstanceId + ); + if (projection.instanceIdByOwnerAndKey[indexKey]) { + throw new InstanceKernelError( + 'TOMBSTONED_KEY_READD_UNSUPPORTED', + `Expansion key ${itemKey} was already observed` + ); + } + const entries = Object.entries(event.nodeInstanceIdsByTemplateNode).sort(([a], [b]) => + a.localeCompare(b) + ); + if (entries.length === 0) { + throw new InstanceKernelError('INVALID_EXPANSION', 'Expanded subgraph must contain nodes'); + } + for (const [templateNodeKey, nodeInstanceId] of entries) { + requireNonEmpty(templateNodeKey, 'Template node key'); + if ( + nodeInstanceId !== deriveNodeInstanceId({ subgraphInstanceId: expectedId, templateNodeKey }) || + projection.nodesById[nodeInstanceId] + ) { + throw new InstanceKernelError('INVALID_NODE_INSTANCE_ID', 'Node instance identity is invalid'); + } + next.nodesById[nodeInstanceId] = { + nodeInstanceId, + subgraphInstanceId: expectedId, + templateNodeKey, + scope, + }; + } + next.instancesById[expectedId] = { + sessionId: event.sessionId, + expansionOwnerCheck: event.expansionOwnerCheck, + graphSemanticDigest: event.graphSemanticDigest, + expansionSpecDigest: event.expansionSpecDigest, + templateDigest: event.templateDigest, + itemKey, + subgraphInstanceId: expectedId, + scope, + catalogClaimId: event.catalogClaimId, + ...(nested + ? { + parentSubgraphInstanceId: event.parentSubgraphInstanceId as string, + expansionOwnerNodeInstanceId: event.expansionOwnerNodeInstanceId as string, + catalogClaimRef: catalogProducer!.nestedExpansionCatalogClaimRef as string, + catalogProducerNodeGenerationId: catalogProducer!.nodeGenerationId, + } + : {}), + nodeInstanceIdsByTemplateNode: { ...event.nodeInstanceIdsByTemplateNode }, + status: 'active', + incarnation: 0, + }; + next.instanceIdByOwnerAndKey[indexKey] = expectedId; + break; + } + case 'ControllerItemClaimPublished': { + const knownInstance = projection.instancesById[event.subgraphInstanceId]; + const instance = requireInstance( + projection, + event.subgraphInstanceId, + event.scope, + knownInstance?.status === 'tombstoned' + ); + let catalogProducer: NodeGenerationProjection | undefined; + if (instance.parentSubgraphInstanceId) { + if (!instance.expansionOwnerNodeInstanceId || !instance.catalogClaimRef) { + throw new InstanceKernelError( + 'INVALID_NESTED_CATALOG_LINEAGE', + 'Nested controller claim requires its immutable catalog authority binding' + ); + } + catalogProducer = requireNestedCatalogAuthority(projection, { + parentSubgraphInstanceId: instance.parentSubgraphInstanceId, + expansionOwnerNodeInstanceId: instance.expansionOwnerNodeInstanceId, + catalogClaimId: event.catalogClaimId, + }); + if ( + instance.catalogClaimRef !== catalogProducer.nestedExpansionCatalogClaimRef + ) { + throw new InstanceKernelError( + 'INVALID_NESTED_CATALOG_LINEAGE', + 'Nested controller claim does not match the parent generation authority' + ); + } + } + if ( + event.sessionId !== instance.sessionId || + event.expansionOwnerCheck !== instance.expansionOwnerCheck || + event.expansionSpecDigest !== instance.expansionSpecDigest || + event.itemKey !== instance.itemKey || + event.incarnation !== instance.incarnation + 1 || + event.parentClaimIds.length !== 1 || + event.parentClaimIds[0] !== event.catalogClaimId + ) { + throw new InstanceKernelError('INVALID_ITEM_CLAIM', 'Controller item claim authority is invalid'); + } + if ( + Object.values(projection.generationsById).some( + generation => + generation.subgraphInstanceId === instance.subgraphInstanceId && + generation.status !== 'inactive' + ) + ) { + throw new InstanceKernelError('EXPANSION_BUSY', 'Old instance generations are still active'); + } + const payloadFingerprint = deriveItemFingerprint(event.payload); + const scope = requireKeyedScopePath(event.scope, instance.scope); + const claimId = deriveControllerItemClaimId({ + claim: event.claim, + payloadFingerprint, + expansionSpecDigest: event.expansionSpecDigest, + catalogClaimId: event.catalogClaimId, + subgraphInstanceId: event.subgraphInstanceId, + incarnation: event.incarnation, + scope, + }); + if ( + event.payloadFingerprint !== payloadFingerprint || + event.claimId !== claimId || + projection.claimsById[event.claimId] + ) { + throw new InstanceKernelError('INVALID_ITEM_CLAIM', 'Controller item claim identity is invalid'); + } + if (instance.activeItemClaimId) { + next.claimsById[instance.activeItemClaimId] = { + ...projection.claimsById[instance.activeItemClaimId], + active: false, + }; + } + next.claimsById[event.claimId] = { + claimId, + claim: event.claim, + payload: immutableCanonicalValue(event.payload), + payloadFingerprint, + producerCheckId: event.expansionOwnerCheck, + controllerCatalogClaimId: event.catalogClaimId, + parentClaimIds: [event.catalogClaimId], + scope, + active: true, + kind: 'controller-item', + subgraphInstanceId: instance.subgraphInstanceId, + incarnation: event.incarnation, + }; + const { tombstoneCatalogClaimId: _tombstoneCatalogClaimId, ...reactivated } = instance; + void _tombstoneCatalogClaimId; + next.instancesById[instance.subgraphInstanceId] = { + ...reactivated, + ...(catalogProducer + ? { + catalogClaimId: event.catalogClaimId, + catalogProducerNodeGenerationId: catalogProducer.nodeGenerationId, + } + : {}), + status: 'active', + incarnation: event.incarnation, + activeItemClaimId: claimId, + }; + break; + } + case 'NodeGenerationActivated': { + const instance = requireInstance(projection, event.subgraphInstanceId, event.scope); + const node = projection.nodesById[event.nodeInstanceId]; + if (event.nestedExpansionCatalogClaimRef !== undefined) { + requireNonEmpty( + event.nestedExpansionCatalogClaimRef, + 'Nested expansion catalog authority' + ); + } + if ( + !node || + node.subgraphInstanceId !== instance.subgraphInstanceId || + node.templateNodeKey !== event.templateNodeKey || + event.checkId !== event.templateNodeKey || + event.incarnation !== instance.incarnation + ) { + throw new InstanceKernelError('INVALID_GENERATION_BINDING', 'Activation is bound to wrong node'); + } + if (projection.activeGenerationIdByNode[event.nodeInstanceId]) { + throw new InstanceKernelError('GENERATION_ALREADY_ACTIVE', 'Node already has an active generation'); + } + const inputIds = sortedUnique(event.activeInputClaimIds, 'Active input claim IDs'); + for (const claimId of inputIds) { + const claim = projection.claimsById[claimId]; + if (!claim?.active || claim.subgraphInstanceId !== instance.subgraphInstanceId) { + throw new InstanceKernelError('INACTIVE_INPUT_CLAIM', `Input claim ${claimId} is not active`); + } + } + const itemClaim = instance.activeItemClaimId + ? projection.claimsById[instance.activeItemClaimId] + : undefined; + if (!itemClaim || itemClaim.payloadFingerprint !== event.itemFingerprint) { + throw new InstanceKernelError('INVALID_ITEM_FINGERPRINT', 'Activation item fingerprint is stale'); + } + const generationId = deriveNodeGenerationId({ + nodeInstanceId: event.nodeInstanceId, + incarnation: event.incarnation, + itemFingerprint: event.itemFingerprint, + executionConfigDigest: event.executionConfigDigest, + activeInputClaimIds: inputIds, + }); + if (event.nodeGenerationId !== generationId || projection.generationsById[generationId]) { + throw new InstanceKernelError('INVALID_GENERATION_ID', 'Node generation identity is invalid'); + } + next.generationsById[generationId] = { + nodeGenerationId: generationId, + nodeInstanceId: node.nodeInstanceId, + subgraphInstanceId: instance.subgraphInstanceId, + templateNodeKey: node.templateNodeKey, + checkId: event.checkId, + scope: requireKeyedScopePath(event.scope, instance.scope), + incarnation: event.incarnation, + itemFingerprint: event.itemFingerprint, + executionConfigDigest: event.executionConfigDigest, + activeInputClaimIds: inputIds, + ...(event.nestedExpansionCatalogClaimRef + ? { nestedExpansionCatalogClaimRef: event.nestedExpansionCatalogClaimRef } + : {}), + status: 'ready', + scheduled: false, + completedOutputClaimIds: [], + }; + next.activeGenerationIdByNode[node.nodeInstanceId] = generationId; + break; + } + case 'NodeGenerationInactivated': { + const generation = requireGeneration(projection, event); + requireInstance(projection, event.subgraphInstanceId, event.scope); + if ( + event.reason !== 'superseded' || + generation.subgraphInstanceId !== event.subgraphInstanceId || + generation.incarnation !== event.incarnation || + generation.status === 'ready' || + generation.status === 'running' + ) { + throw new InstanceKernelError('EXPANSION_BUSY', 'Generation cannot be inactivated now'); + } + if ( + Object.values(projection.instancesById).some( + candidate => + candidate.status === 'active' && + candidate.catalogProducerNodeGenerationId === generation.nodeGenerationId + ) + ) { + throw new InstanceKernelError( + 'ACTIVE_DESCENDANT', + 'Nested descendants must be tombstoned before their catalog producer is inactivated' + ); + } + const outputIds = sortedUnique(event.outputClaimIds, 'Inactivated output claim IDs'); + const expectedOutputs = [...generation.completedOutputClaimIds].sort(); + if (!sameStrings(outputIds, expectedOutputs)) { + throw new InstanceKernelError('INVALID_GENERATION_BINDING', 'Inactivation output set is not exact'); + } + next.generationsById[event.nodeGenerationId] = { ...generation, status: 'inactive' }; + delete next.activeGenerationIdByNode[event.nodeInstanceId]; + for (const claimId of outputIds) { + next.claimsById[claimId] = { ...projection.claimsById[claimId], active: false }; + } + break; + } + case 'SubgraphTombstoned': { + const instance = requireInstance(projection, event.subgraphInstanceId, event.scope); + if ( + event.expansionOwnerCheck !== instance.expansionOwnerCheck || + event.itemKey !== instance.itemKey || + event.lastIncarnation !== instance.incarnation + ) { + throw new InstanceKernelError('INVALID_TOMBSTONE', 'Tombstone identity is invalid'); + } + if ( + Object.values(projection.instancesById).some( + candidate => + candidate.status === 'active' && + candidate.parentSubgraphInstanceId === instance.subgraphInstanceId + ) + ) { + throw new InstanceKernelError( + 'ACTIVE_DESCENDANT', + 'Nested descendants must be tombstoned before their parent instance' + ); + } + const activeGenerations = Object.values(projection.generationsById) + .filter( + generation => + generation.subgraphInstanceId === instance.subgraphInstanceId && + generation.status !== 'inactive' + ) + .sort((left, right) => left.nodeGenerationId.localeCompare(right.nodeGenerationId)); + if (activeGenerations.some(generation => generation.status === 'ready' || generation.status === 'running')) { + throw new InstanceKernelError('EXPANSION_BUSY', 'Ready or running generation blocks tombstone'); + } + const generationIds = sortedUnique(event.nodeGenerationIds, 'Tombstone generation IDs'); + if (!sameStrings(generationIds, activeGenerations.map(generation => generation.nodeGenerationId))) { + throw new InstanceKernelError('INVALID_TOMBSTONE', 'Tombstone generation set is not exact'); + } + const expectedOutputs = activeGenerations + .flatMap(generation => generation.completedOutputClaimIds) + .sort(); + const outputIds = sortedUnique(event.outputClaimIds, 'Tombstone output claim IDs'); + if (!sameStrings(outputIds, expectedOutputs)) { + throw new InstanceKernelError('INVALID_TOMBSTONE', 'Tombstone output set is not exact'); + } + for (const generation of activeGenerations) { + next.generationsById[generation.nodeGenerationId] = { ...generation, status: 'inactive' }; + delete next.activeGenerationIdByNode[generation.nodeInstanceId]; + } + for (const claimId of outputIds) { + next.claimsById[claimId] = { ...projection.claimsById[claimId], active: false }; + } + if (instance.activeItemClaimId) { + next.claimsById[instance.activeItemClaimId] = { + ...projection.claimsById[instance.activeItemClaimId], + active: false, + }; + } + next.instancesById[instance.subgraphInstanceId] = { + ...instance, + status: 'tombstoned', + tombstoneCatalogClaimId: event.sourceCatalogClaimId, + }; + break; + } + case 'AttemptStarted': + case 'CheckScheduled': + case 'AttemptCompleted': + case 'AttemptFailed': { + if ('requestId' in event) reduceRequestLifecycle(projection, next, event); + else reduceGeneratedLifecycle(projection, next, event); + break; + } + case 'ClaimPublished': + reduceGeneratedLifecycle(projection, next, event); + break; + case 'ManagedRunAcquisitionFailed': + case 'ManagedRunAcquired': + case 'ManagedRunStarted': + case 'ManagedRunCancelRequested': + case 'ManagedRunTerminated': + reduceManagedRunLifecycle(projection, next, event); + break; + } + + return immutableCanonicalValue(next); +} + +function isGeneratedAttemptTerminal( + event: InstanceRuntimeEvent +): event is GeneratedAttemptCompletedEvent | GeneratedAttemptFailedEvent { + return ( + (event.type === 'AttemptCompleted' || event.type === 'AttemptFailed') && + 'nodeGenerationId' in event + ); +} + +function requireMatchingAttemptTerminal( + binding: ManagedRunBindingV1, + event: InstanceRuntimeEvent | undefined, + expectedType: 'AttemptCompleted' | 'AttemptFailed', + failureCode?: ManagedRunFailureCode +): void { + if ( + !event || + !isGeneratedAttemptTerminal(event) || + event.type !== expectedType || + !generatedAttemptMatchesManagedBinding(event, binding) || + (event.type === 'AttemptFailed' && event.reason !== failureCode) + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + `Managed lifecycle terminal requires a matching ${expectedType} in the same batch` + ); + } +} + +function isNestedReconciliationEvent(event: InstanceRuntimeEvent): boolean { + return ( + event.type === 'SubgraphExpanded' || + event.type === 'ControllerItemClaimPublished' || + event.type === 'NodeGenerationInactivated' || + event.type === 'SubgraphTombstoned' + ); +} + +function nestedCatalogClaimId(event: InstanceRuntimeEvent): string | undefined { + if (event.type === 'SubgraphExpanded' || event.type === 'ControllerItemClaimPublished') { + return event.catalogClaimId; + } + if (event.type === 'SubgraphTombstoned') return event.sourceCatalogClaimId; + return undefined; +} + +function requireManagedNestedReconciliationAuthority( + projection: InstanceProjection, + event: InstanceRuntimeEvent, + binding: ManagedRunBindingV1, + catalogClaimId: string +): void { + const scope = requireKeyedScopePath(event.scope); + const parentScope = scope.slice(0, binding.scope.length); + const parentSegment = binding.scope[binding.scope.length - 1]; + if ( + scope.length !== binding.scope.length + 1 || + !scopePathEquals(parentScope, binding.scope) + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed nested reconciliation scope must have the exact binding-scope prefix' + ); + } + if (!('subgraphInstanceId' in event)) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed nested reconciliation event lacks a child instance identity' + ); + } + + const ownerGeneration = projection.generationsById[binding.nodeGenerationId]; + const catalogClaimRef = ownerGeneration?.nestedExpansionCatalogClaimRef; + if (!catalogClaimRef) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed parent owner generation lacks nested catalog authority' + ); + } + if (event.type === 'SubgraphExpanded') { + if ( + event.parentSubgraphInstanceId !== parentSegment.subgraphInstanceId || + event.expansionOwnerNodeInstanceId !== binding.nodeInstanceId || + event.catalogClaimRef !== catalogClaimRef || + event.catalogClaimId !== catalogClaimId + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed child expansion is bound to a foreign parent, owner node, or catalog' + ); + } + } else { + const instance = projection.instancesById[event.subgraphInstanceId]; + if ( + !instance || + instance.parentSubgraphInstanceId !== parentSegment.subgraphInstanceId || + instance.expansionOwnerNodeInstanceId !== binding.nodeInstanceId || + instance.catalogClaimRef !== catalogClaimRef + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed nested event is not bound to the exact parent instance and owner node' + ); + } + if ( + event.type === 'ControllerItemClaimPublished' && + event.catalogClaimId !== catalogClaimId + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed controller item claim is bound to a foreign catalog' + ); + } + if ( + event.type === 'SubgraphTombstoned' && + event.sourceCatalogClaimId !== catalogClaimId + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed child tombstone is bound to a foreign catalog' + ); + } + } + + const producer = requireNestedCatalogAuthority(projection, { + parentSubgraphInstanceId: parentSegment.subgraphInstanceId, + expansionOwnerNodeInstanceId: binding.nodeInstanceId, + catalogClaimId, + }); + if ( + producer.nodeGenerationId !== binding.nodeGenerationId || + producer.nodeInstanceId !== binding.nodeInstanceId || + producer.attemptId !== binding.attemptId || + producer.fence !== binding.fence || + producer.checkId !== binding.checkId + ) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed nested catalog does not have the current complete fenced binding lineage' + ); + } +} + +/** + * Pure atomic-batch validator/reducer. Callers publish none of the input events + * unless this function returns the fully reduced immutable projection. + */ +export function reduceInstanceEventBatch( + projection: InstanceProjection, + events: readonly InstanceRuntimeEvent[] +): InstanceProjection { + for (let index = 0; index < events.length; index++) { + const event = events[index]; + if (event.type === 'ManagedRunAcquisitionFailed') { + if (index !== 0 || events.length !== 2) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed acquisition failure batch must contain exactly its two adjacent terminals' + ); + } + requireMatchingAttemptTerminal( + event.binding, + events[index + 1], + 'AttemptFailed', + event.failureCode + ); + } else if (event.type === 'ManagedRunTerminated') { + if (event.controllerDecision === 'failed') { + if (index !== 0 || events.length !== 2) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed failure batch must contain exactly its two adjacent terminals' + ); + } + requireMatchingAttemptTerminal( + event.binding, + events[index + 1], + 'AttemptFailed', + event.failureCode || undefined + ); + } else { + if (index !== 0) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed completion lifecycle terminal must begin its atomic batch' + ); + } + const completionIndex = events.findIndex( + (candidate, candidateIndex) => + candidateIndex > index && + isGeneratedAttemptTerminal(candidate) && + candidate.attemptId === event.binding.attemptId + ); + const completion = completionIndex < 0 ? undefined : events[completionIndex]; + requireMatchingAttemptTerminal(event.binding, completion, 'AttemptCompleted'); + if (completionIndex !== events.length - 1) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed AttemptCompleted must end its atomic terminal batch' + ); + } + const interior = events.slice(1, -1); + const nestedEvents = interior.filter(candidate => + isNestedReconciliationEvent(candidate) || + (candidate.type === 'NodeGenerationActivated' && + candidate.scope.length === event.binding.scope.length + 1) + ); + const nestedCatalogClaimIds = new Set( + nestedEvents + .map(nestedCatalogClaimId) + .filter((claimId): claimId is string => claimId !== undefined) + ); + if (nestedEvents.length > 0 && nestedCatalogClaimIds.size !== 1) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed nested reconciliation requires one exact catalog authority' + ); + } + const catalogClaimId = [...nestedCatalogClaimIds][0]; + let nonClaimObserved = false; + let stagedProjection = reduceInstanceEvent(projection, event); + for (const staged of interior) { + if (staged.type === 'ClaimPublished') { + if (nonClaimObserved) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed completion claims must precede reconciliation and downstream activation' + ); + } + stagedProjection = reduceInstanceEvent(stagedProjection, staged); + continue; + } + nonClaimObserved = true; + const nested = + isNestedReconciliationEvent(staged) || + (staged.type === 'NodeGenerationActivated' && + staged.scope.length === event.binding.scope.length + 1); + if (nested && catalogClaimId) { + requireManagedNestedReconciliationAuthority( + stagedProjection, + staged, + event.binding, + catalogClaimId + ); + stagedProjection = reduceInstanceEvent(stagedProjection, staged); + continue; + } + if ( + staged.type === 'NodeGenerationActivated' && + scopePathEquals(staged.scope, event.binding.scope) && + staged.subgraphInstanceId === + event.binding.scope[event.binding.scope.length - 1].subgraphInstanceId + ) { + stagedProjection = reduceInstanceEvent(stagedProjection, staged); + continue; + } + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed completion batch contains an event outside the exact nested reconciliation grammar' + ); + } + } + } + } + + for (const event of events) { + if (!isGeneratedAttemptTerminal(event)) continue; + const existing = projection.managedRunsByAttemptId[event.attemptId]; + const matchingLifecycle = events.find( + candidate => + (candidate.type === 'ManagedRunAcquisitionFailed' || + candidate.type === 'ManagedRunTerminated') && + generatedAttemptMatchesManagedBinding(event, candidate.binding) + ); + if (existing && !matchingLifecycle) { + throw new InstanceKernelError( + 'INVALID_MANAGED_BATCH', + 'Managed attempt terminal cannot bypass its same-batch lifecycle terminal' + ); + } + } + + return events.reduce(reduceInstanceEvent, projection); +} + +/** + * Pure replay preserves the atomic terminal boundaries encoded by contiguous + * journal order. Batch-only managed terminals are never reduced as singles. + */ +export function replayInstanceEvents(events: readonly InstanceRuntimeEvent[]): InstanceProjection { + let projection = createInitialInstanceProjection(); + let index = 0; + while (index < events.length) { + const event = events[index]; + if ( + event.type === 'ManagedRunAcquisitionFailed' || + (event.type === 'ManagedRunTerminated' && event.controllerDecision === 'failed') + ) { + projection = reduceInstanceEventBatch(projection, events.slice(index, index + 2)); + index += 2; + continue; + } + if (event.type === 'ManagedRunTerminated') { + let terminalIndex = index + 1; + while (terminalIndex < events.length) { + const candidate = events[terminalIndex]; + if ( + isGeneratedAttemptTerminal(candidate) && + candidate.attemptId === event.binding.attemptId + ) { + break; + } + terminalIndex++; + } + projection = reduceInstanceEventBatch( + projection, + events.slice(index, Math.min(terminalIndex + 1, events.length)) + ); + index = terminalIndex + 1; + continue; + } + projection = reduceInstanceEventBatch(projection, [event]); + index++; + } + return projection; +} + +export function queryReadyGenerations(projection: InstanceProjection): readonly NodeGenerationProjection[] { + return Object.values(projection.generationsById) + .filter(generation => generation.status === 'ready') + .sort((left, right) => left.nodeGenerationId.localeCompare(right.nodeGenerationId)); +} diff --git a/src/state-machine/graph/instance-plan.ts b/src/state-machine/graph/instance-plan.ts new file mode 100644 index 000000000..7176e2a89 --- /dev/null +++ b/src/state-machine/graph/instance-plan.ts @@ -0,0 +1,960 @@ +import type { + CheckConfig, + ClaimConsumptionConfig, + ClaimEmissionConfig, + ClaimTypeConfig, + ExpansionConfig, + SubgraphConfig, + VisorConfig, +} from '../../types/config'; +import { + immutableCanonicalValue, + sha256Canonical, + type ClaimSchemaValidator, +} from './claim-kernel'; + +const CLAIM_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*@[1-9][0-9]*$/; +const BINDING_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/; + +/** Reserved EXP-0205 admission profile identifiers. */ +export const PROOF_CANDIDATE_CLAIM = 'proof.candidate@1'; +export const PROOF_ADMITTED_RECEIPT_CLAIM = 'proof.admitted_receipt@1'; +export const PROOF_ADMIT_PROVIDER_TYPE = 'proof-admit'; +export const PROOF_ADMIT_NODE_KEY = 'proof_admit'; + +export class InstancePlanError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'InstancePlanError'; + this.code = code; + } +} + +export interface CompiledJsonPointer { + readonly source: string; + readonly tokens: readonly string[]; +} + +export interface CompiledTemplateNode { + readonly templateNodeKey: string; + readonly check: CheckConfig; + readonly emissions: readonly ClaimEmissionConfig[]; + readonly consumptions: readonly Required[]; + readonly dependencyNodeKeys: readonly string[]; + readonly executionConfigDigest: string; +} + +export interface CompiledSubgraphTemplate { + readonly name: string; + readonly input: Readonly<{ name: string; claim: string }>; + readonly templateDigest: string; + readonly templateNodeKeys: readonly string[]; + readonly topology: readonly string[]; + readonly reverseTopology: readonly string[]; + readonly sourceNodeKeys: readonly string[]; + readonly nodesByKey: Readonly>; + readonly emitterByClaim: Readonly>; + readonly dependentsByNode: Readonly>; +} + +export interface CompiledExpansion { + readonly expansionOwnerCheck: string; + readonly depth: 1 | 2; + readonly parentTemplateName: string | null; + readonly parentTemplateNodeKey: string | null; + readonly catalogClaimRef: string; + readonly catalogValidator: ClaimSchemaValidator; + readonly templateName: string; + readonly templateDigest: string; + readonly expansionSpecDigest: string; + readonly itemsPointer: CompiledJsonPointer; + readonly keyPointer: CompiledJsonPointer; + readonly itemClaimRef: string; + readonly itemValidator: ClaimSchemaValidator; + readonly template: CompiledSubgraphTemplate; + readonly coverage?: Readonly<{ + outcomeClaimRef: string; + classPointer: CompiledJsonPointer; + emitterNodeKey: string; + }>; + readonly graphSemanticDigest: string; +} + +export interface ExpansionPlan { + readonly active: boolean; + readonly graphSemanticDigest: string; + readonly byOwner: Readonly>; + readonly byNestedOwner: Readonly>; + readonly templatesByName: Readonly>; +} + +export interface ExpansionCompileAuthority { + readonly claimTypes: Readonly>; + readonly validatorsByClaim: Readonly>; + readonly rootEmitterByClaim: Readonly>; +} + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +/** Unambiguous static address for one generated expansion owner. */ +export function qualifiedNestedExpansionOwner( + parentTemplateName: string, + parentTemplateNodeKey: string +): string { + return JSON.stringify([parentTemplateName, parentTemplateNodeKey]); +} + +function frozenRecord(record: Record): Readonly> { + return Object.freeze(record); +} + +function requireNonEmptyString(value: unknown, field: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new InstancePlanError('INVALID_EXPANSION_CONFIG', `${field} must be a non-empty string`); + } + return value; +} + +/** Strict RFC 6901 syntax compilation; no executable selector language is accepted. */ +export function compileJsonPointer(pointer: unknown, field: string): CompiledJsonPointer { + if (typeof pointer !== 'string' || (pointer !== '' && !pointer.startsWith('/'))) { + throw new InstancePlanError( + 'INVALID_JSON_POINTER', + `${field} must be an RFC 6901 JSON Pointer` + ); + } + const tokens = pointer === '' ? [] : pointer.slice(1).split('/'); + const decoded = tokens.map(token => { + if (/~(?:[^01]|$)/.test(token)) { + throw new InstancePlanError( + 'INVALID_JSON_POINTER', + `${field} contains an invalid RFC 6901 escape` + ); + } + return token.replace(/~1/g, '/').replace(/~0/g, '~'); + }); + return Object.freeze({ source: pointer, tokens: Object.freeze(decoded) }); +} + +/** Resolve a previously compiled pointer without coercion or fallback lookup. */ +export function resolveJsonPointer(value: unknown, pointer: CompiledJsonPointer): unknown { + let current = value; + for (const token of pointer.tokens) { + if (Array.isArray(current)) { + if (!/^(0|[1-9][0-9]*)$/.test(token)) { + throw new InstancePlanError( + 'JSON_POINTER_NOT_FOUND', + `Pointer ${pointer.source} does not resolve exactly` + ); + } + const index = Number(token); + if (!Number.isSafeInteger(index) || index >= current.length) { + throw new InstancePlanError( + 'JSON_POINTER_NOT_FOUND', + `Pointer ${pointer.source} does not resolve exactly` + ); + } + current = current[index]; + continue; + } + if ( + !current || + typeof current !== 'object' || + !hasOwn(current as object, token) + ) { + throw new InstancePlanError( + 'JSON_POINTER_NOT_FOUND', + `Pointer ${pointer.source} does not resolve exactly` + ); + } + current = (current as Record)[token]; + } + return current; +} + +function dependencyTokens(check: CheckConfig, checkId: string): string[] { + const raw = check.depends_on; + const tokens = Array.isArray(raw) ? raw : raw ? [raw] : []; + const orToken = tokens.find(token => token.includes('|')); + if (orToken) { + throw new InstancePlanError( + 'UNSUPPORTED_TEMPLATE_OR_DEPENDENCY', + `Template check "${checkId}" uses unsupported OR dependency token "${orToken}"` + ); + } + return tokens; +} + +function hasRouting(check: CheckConfig): boolean { + return ['on_init', 'on_success', 'on_fail', 'on_finish'].some(field => { + if (!hasOwn(check, field)) return false; + const value = check[field as keyof CheckConfig]; + return value !== undefined && value !== null; + }); +} + +function resolvedTemplateCheck(check: CheckConfig): CheckConfig { + const consumptions = (check.consumes || []).map(consumption => ({ + ...consumption, + cardinality: 'one' as const, + })); + return immutableCanonicalValue({ + ...check, + type: check.type || 'ai', + ...(check.consumes ? { consumes: consumptions } : {}), + }); +} + +function claimList(check: CheckConfig, field: 'emits' | 'consumes'): string[] { + return (check[field] || []).map(declaration => declaration.claim).sort(); +} + +function rejectReservedProfile(templateName: string, detail: string): never { + throw new InstancePlanError( + 'RESERVED_PROOF_ADMISSION_PROFILE', + `Subgraph template "${templateName}" violates the reserved proof admission profile: ${detail}` + ); +} + +/** + * The proof admission node is deliberately a fixed, tiny profile. It is + * validated after all ordinary declaration, emitter, dependency, and topology + * checks so no alternate claim path can be smuggled through a template. + */ +function validateReservedProofAdmissionTemplate( + name: string, + inputClaim: string, + nodeKeys: readonly string[], + resolvedChecks: Readonly>, + consumptionsByNode: Readonly[]>>, + topology: readonly string[], + authority: ExpansionCompileAuthority +): void { + const triggered = nodeKeys.some(nodeKey => { + const check = resolvedChecks[nodeKey]; + return ( + check.type === PROOF_ADMIT_PROVIDER_TYPE || + claimList(check, 'emits').some( + claim => claim === PROOF_CANDIDATE_CLAIM || claim === PROOF_ADMITTED_RECEIPT_CLAIM + ) || + claimList(check, 'consumes').some( + claim => claim === PROOF_CANDIDATE_CLAIM || claim === PROOF_ADMITTED_RECEIPT_CLAIM + ) + ); + }); + if (!triggered) return; + + if (!hasOwn(authority.claimTypes, PROOF_CANDIDATE_CLAIM)) { + rejectReservedProfile(name, `missing ${PROOF_CANDIDATE_CLAIM} declaration`); + } + if (!hasOwn(authority.claimTypes, PROOF_ADMITTED_RECEIPT_CLAIM)) { + rejectReservedProfile(name, `missing ${PROOF_ADMITTED_RECEIPT_CLAIM} declaration`); + } + if ( + inputClaim === PROOF_CANDIDATE_CLAIM || + inputClaim === PROOF_ADMITTED_RECEIPT_CLAIM + ) { + rejectReservedProfile(name, 'a reserved claim cannot be the template input'); + } + + const expectedNodes = ['inspect', PROOF_ADMIT_NODE_KEY, 'verify']; + if (nodeKeys.length !== expectedNodes.length || nodeKeys.some((key, index) => key !== expectedNodes[index])) { + rejectReservedProfile(name, `expected exactly the nodes ${expectedNodes.join(', ')}`); + } + if (topology.join('\0') !== expectedNodes.join('\0')) { + rejectReservedProfile(name, `expected topology ${expectedNodes.join(' -> ')}`); + } + + for (const nodeKey of nodeKeys) { + const check = resolvedChecks[nodeKey]; + if (hasOwn(check, 'expand')) rejectReservedProfile(name, `${nodeKey} cannot use check.expand`); + if (nodeKey !== PROOF_ADMIT_NODE_KEY && check.type === PROOF_ADMIT_PROVIDER_TYPE) { + rejectReservedProfile(name, `provider type ${PROOF_ADMIT_PROVIDER_TYPE} is only valid at ${PROOF_ADMIT_NODE_KEY}`); + } + } + + const inspect = resolvedChecks.inspect; + if (claimList(inspect, 'emits').join('\0') !== PROOF_CANDIDATE_CLAIM) { + rejectReservedProfile(name, `inspect must emit only ${PROOF_CANDIDATE_CLAIM}`); + } + if ( + claimList(inspect, 'consumes').length !== 1 || + claimList(inspect, 'consumes')[0] !== inputClaim + ) { + rejectReservedProfile(name, 'inspect must consume only the template input claim'); + } + + const proofAdmit = resolvedChecks[PROOF_ADMIT_NODE_KEY]; + if (proofAdmit.type !== PROOF_ADMIT_PROVIDER_TYPE) { + rejectReservedProfile(name, `${PROOF_ADMIT_NODE_KEY} must have type ${PROOF_ADMIT_PROVIDER_TYPE}`); + } + if (claimList(proofAdmit, 'emits').join('\0') !== PROOF_ADMITTED_RECEIPT_CLAIM) { + rejectReservedProfile(name, `${PROOF_ADMIT_NODE_KEY} must emit only ${PROOF_ADMITTED_RECEIPT_CLAIM}`); + } + if (claimList(proofAdmit, 'consumes').join('\0') !== PROOF_CANDIDATE_CLAIM) { + rejectReservedProfile(name, `${PROOF_ADMIT_NODE_KEY} must consume only ${PROOF_CANDIDATE_CLAIM}`); + } + + const verify = resolvedChecks.verify; + if (verify.type === PROOF_ADMIT_PROVIDER_TYPE) { + rejectReservedProfile(name, 'verify cannot use the proof admission provider'); + } + if ( + claimList(verify, 'emits').length !== 0 || + claimList(verify, 'consumes').join('\0') !== + [PROOF_CANDIDATE_CLAIM, PROOF_ADMITTED_RECEIPT_CLAIM].sort().join('\0') + ) { + rejectReservedProfile(name, 'verify must consume both reserved claims and emit none'); + } + + // Keep this assertion close to the profile so future changes cannot make + // the candidate-consumer exemption implicit or broaden it accidentally. + if (consumptionsByNode[PROOF_ADMIT_NODE_KEY].length !== 1) { + rejectReservedProfile(name, `${PROOF_ADMIT_NODE_KEY} must have exactly one candidate consumer`); + } +} + +function topologicalOrder( + templateName: string, + dependencies: Readonly> +): readonly string[] { + const remaining = new Map( + Object.entries(dependencies).map(([node, values]) => [node, new Set(values)]) + ); + const order: string[] = []; + while (remaining.size > 0) { + const ready = [...remaining.entries()] + .filter(([, values]) => values.size === 0) + .map(([node]) => node) + .sort(); + if (ready.length === 0) { + throw new InstancePlanError( + 'TEMPLATE_CYCLE', + `Subgraph template "${templateName}" contains a dependency cycle` + ); + } + for (const node of ready) { + remaining.delete(node); + order.push(node); + } + for (const values of remaining.values()) { + for (const node of ready) values.delete(node); + } + } + return Object.freeze(order); +} + +function compileTemplate( + name: string, + authored: SubgraphConfig, + authority: ExpansionCompileAuthority +): CompiledSubgraphTemplate { + if (!authored || typeof authored !== 'object' || Array.isArray(authored)) { + throw new InstancePlanError('INVALID_SUBGRAPH_TEMPLATE', `Subgraph "${name}" must be an object`); + } + const inputName = requireNonEmptyString(authored.input?.name, `subgraphs.${name}.input.name`); + if (!BINDING_NAME_PATTERN.test(inputName)) { + throw new InstancePlanError( + 'INVALID_TEMPLATE_BINDING', + `Subgraph "${name}" input name "${inputName}" is not a canonical binding name` + ); + } + const inputClaim = requireNonEmptyString( + authored.input?.claim, + `subgraphs.${name}.input.claim` + ); + if (!CLAIM_REF_PATTERN.test(inputClaim) || !hasOwn(authority.claimTypes, inputClaim)) { + throw new InstancePlanError( + 'UNKNOWN_TEMPLATE_CLAIM', + `Subgraph "${name}" references undeclared input claim "${inputClaim}"` + ); + } + if (!authored.checks || typeof authored.checks !== 'object' || Array.isArray(authored.checks)) { + throw new InstancePlanError( + 'INVALID_SUBGRAPH_TEMPLATE', + `Subgraph "${name}" requires a checks map` + ); + } + const nodeKeys = Object.keys(authored.checks).sort(); + if (nodeKeys.length === 0 || nodeKeys.some(node => node.length === 0)) { + throw new InstancePlanError( + 'INVALID_SUBGRAPH_TEMPLATE', + `Subgraph "${name}" requires at least one named check` + ); + } + + const resolvedChecks: Record = {}; + const emitterByClaim: Record = {}; + const consumptionsByNode: Record[]> = {}; + let consumesTemplateInput = false; + + for (const nodeKey of nodeKeys) { + const check = authored.checks[nodeKey]; + if (!check || typeof check !== 'object' || Array.isArray(check)) { + throw new InstancePlanError( + 'INVALID_TEMPLATE_CHECK', + `Template check "${name}.${nodeKey}" must be an object` + ); + } + if (check.forEach || check.type === 'workflow' || hasRouting(check)) { + throw new InstancePlanError( + 'UNSUPPORTED_TEMPLATE_EXECUTION', + `Template check "${name}.${nodeKey}" cannot use forEach, workflow, or lifecycle routing` + ); + } + for (const field of ['emits', 'consumes'] as const) { + if (hasOwn(check, field) && (!Array.isArray(check[field]) || check[field]!.length === 0)) { + throw new InstancePlanError( + 'EMPTY_TEMPLATE_CLAIM_DECLARATION', + `Template check "${name}.${nodeKey}" declares ${field}, which must be a non-empty array` + ); + } + } + + const resolved = resolvedTemplateCheck(check); + const seenClaims = new Set(); + const seenBindings = new Set(); + const consumptions = (resolved.consumes || []).map(consumption => { + if ( + !CLAIM_REF_PATTERN.test(consumption.claim) || + !hasOwn(authority.claimTypes, consumption.claim) + ) { + throw new InstancePlanError( + 'UNKNOWN_TEMPLATE_CLAIM', + `Template check "${name}.${nodeKey}" consumes undeclared claim "${consumption.claim}"` + ); + } + if (consumption.cardinality !== 'one') { + throw new InstancePlanError( + 'UNSUPPORTED_TEMPLATE_CARDINALITY', + `Template check "${name}.${nodeKey}" supports cardinality one only` + ); + } + const binding = requireNonEmptyString( + consumption.as, + `subgraphs.${name}.checks.${nodeKey}.consumes.as` + ); + if (!BINDING_NAME_PATTERN.test(binding)) { + throw new InstancePlanError( + 'INVALID_TEMPLATE_BINDING', + `Template check "${name}.${nodeKey}" has invalid binding "${binding}"` + ); + } + if (seenClaims.has(consumption.claim) || seenBindings.has(binding)) { + throw new InstancePlanError( + 'DUPLICATE_TEMPLATE_CONSUMPTION', + `Template check "${name}.${nodeKey}" has a duplicate claim or binding` + ); + } + if (consumption.claim === inputClaim) { + consumesTemplateInput = true; + if (binding !== inputName) { + throw new InstancePlanError( + 'INVALID_TEMPLATE_BINDING', + `Template input claim "${inputClaim}" must bind as "${inputName}"` + ); + } + } + seenClaims.add(consumption.claim); + seenBindings.add(binding); + return Object.freeze({ + claim: consumption.claim, + cardinality: 'one' as const, + as: binding, + }); + }); + consumptionsByNode[nodeKey] = Object.freeze(consumptions); + + for (const emission of resolved.emits || []) { + if (!CLAIM_REF_PATTERN.test(emission.claim) || !hasOwn(authority.claimTypes, emission.claim)) { + throw new InstancePlanError( + 'UNKNOWN_TEMPLATE_CLAIM', + `Template check "${name}.${nodeKey}" emits undeclared claim "${emission.claim}"` + ); + } + if (emission.from !== 'output') { + throw new InstancePlanError( + 'UNSUPPORTED_TEMPLATE_CLAIM_SOURCE', + `Template check "${name}.${nodeKey}" uses an unsupported claim source` + ); + } + if (emission.claim === inputClaim) { + throw new InstancePlanError( + 'FORGED_CONTROLLER_ITEM_CLAIM', + `Template check "${name}.${nodeKey}" cannot emit controller input claim "${inputClaim}"` + ); + } + const existing = emitterByClaim[emission.claim]; + if (existing) { + throw new InstancePlanError( + 'DUPLICATE_TEMPLATE_EMITTER', + `Template claim "${emission.claim}" has duplicate emitters "${existing}" and "${nodeKey}"` + ); + } + emitterByClaim[emission.claim] = nodeKey; + } + resolvedChecks[nodeKey] = resolved; + } + if (!consumesTemplateInput) { + throw new InstancePlanError( + 'UNUSED_TEMPLATE_INPUT', + `Subgraph "${name}" has no check consuming its input claim "${inputClaim}"` + ); + } + + const dependencies: Record = {}; + for (const nodeKey of nodeKeys) { + const effective = new Set(dependencyTokens(resolvedChecks[nodeKey], `${name}.${nodeKey}`)); + for (const dependency of effective) { + if (!hasOwn(resolvedChecks, dependency)) { + throw new InstancePlanError( + 'UNKNOWN_TEMPLATE_CHECK', + `Template check "${name}.${nodeKey}" depends on unknown check "${dependency}"` + ); + } + } + for (const consumption of consumptionsByNode[nodeKey]) { + if (consumption.claim === inputClaim) continue; + const emitter = emitterByClaim[consumption.claim]; + if (!emitter) { + throw new InstancePlanError( + 'MISSING_TEMPLATE_EMITTER', + `Template claim "${consumption.claim}" consumed by "${name}.${nodeKey}" has no template emitter` + ); + } + effective.add(emitter); + } + dependencies[nodeKey] = Object.freeze([...effective].sort()); + } + const topology = topologicalOrder(name, dependencies); + validateReservedProofAdmissionTemplate( + name, + inputClaim, + nodeKeys, + resolvedChecks, + consumptionsByNode, + topology, + authority + ); + const dependentsByNode: Record = {}; + for (const nodeKey of nodeKeys) { + dependentsByNode[nodeKey] = Object.freeze( + nodeKeys.filter(candidate => dependencies[candidate].includes(nodeKey)).sort() + ); + } + + const input = Object.freeze({ name: inputName, claim: inputClaim }); + const resolvedTemplate = immutableCanonicalValue({ + name, + input, + checks: resolvedChecks, + }); + const templateDigest = sha256Canonical({ v: 1, template: resolvedTemplate }); + const nodesByKey: Record = {}; + for (const nodeKey of nodeKeys) { + const check = resolvedChecks[nodeKey]; + const executionConfigDigest = sha256Canonical({ + v: 1, + templateDigest, + templateNodeKey: nodeKey, + resolvedCheck: check, + }); + nodesByKey[nodeKey] = Object.freeze({ + templateNodeKey: nodeKey, + check, + emissions: Object.freeze([...(check.emits || [])]), + consumptions: consumptionsByNode[nodeKey], + dependencyNodeKeys: dependencies[nodeKey], + executionConfigDigest, + }); + } + + return Object.freeze({ + name, + input, + templateDigest, + templateNodeKeys: Object.freeze(nodeKeys), + topology, + reverseTopology: Object.freeze([...topology].reverse()), + sourceNodeKeys: Object.freeze(nodeKeys.filter(node => dependencies[node].length === 0)), + nodesByKey: frozenRecord(nodesByKey), + emitterByClaim: frozenRecord(emitterByClaim), + dependentsByNode: frozenRecord(dependentsByNode), + }); +} + +function resolvedRootChecks(checks: Record): Readonly> { + const resolved: Record = {}; + for (const checkId of Object.keys(checks).sort()) { + resolved[checkId] = immutableCanonicalValue({ + ...checks[checkId], + type: checks[checkId].type || 'ai', + }); + } + return frozenRecord(resolved); +} + +/** Compile all dynamic-instance authority once, before any provider can launch. */ +export function compileExpansionPlan( + config: Partial, + authority: ExpansionCompileAuthority +): ExpansionPlan { + const checks = config.checks || config.steps || {}; + const subgraphs = config.subgraphs; + const owners = Object.entries(checks).filter(([, check]) => hasOwn(check, 'expand')); + const hasSubgraphs = hasOwn(config, 'subgraphs'); + if (!hasSubgraphs && owners.length === 0) { + return Object.freeze({ + active: false, + graphSemanticDigest: sha256Canonical({ v: 1, active: false }), + byOwner: frozenRecord({}), + byNestedOwner: frozenRecord({}), + templatesByName: frozenRecord({}), + }); + } + if ( + !subgraphs || + typeof subgraphs !== 'object' || + Array.isArray(subgraphs) || + Object.keys(subgraphs).length === 0 || + owners.length === 0 + ) { + throw new InstancePlanError( + 'INCOMPLETE_EXPANSION_CONFIG', + 'Graph v2 C2 requires both a non-empty subgraphs map and a check-local expand block' + ); + } + + const templatesByName: Record = {}; + for (const name of Object.keys(subgraphs).sort()) { + requireNonEmptyString(name, 'subgraph name'); + templatesByName[name] = compileTemplate(name, subgraphs[name], authority); + } + + const precompiled: Array<{ + owner: string; + expansion: ExpansionConfig; + template: CompiledSubgraphTemplate; + itemsPointer: CompiledJsonPointer; + keyPointer: CompiledJsonPointer; + coverage?: CompiledExpansion['coverage']; + expansionSpecDigest: string; + }> = []; + for (const [owner, check] of owners.sort(([a], [b]) => a.localeCompare(b))) { + const expansion = check.expand; + if (!expansion || typeof expansion !== 'object' || Array.isArray(expansion)) { + throw new InstancePlanError( + 'INVALID_EXPANSION_CONFIG', + `Check "${owner}" expand must be an object` + ); + } + const catalogClaim = requireNonEmptyString(expansion.claim, `checks.${owner}.expand.claim`); + if (!CLAIM_REF_PATTERN.test(catalogClaim) || !authority.validatorsByClaim[catalogClaim]) { + throw new InstancePlanError( + 'UNKNOWN_EXPANSION_CLAIM', + `Check "${owner}" expands undeclared catalog claim "${catalogClaim}"` + ); + } + const matchingEmissions = (check.emits || []).filter(emission => emission.claim === catalogClaim); + if (matchingEmissions.length !== 1 || authority.rootEmitterByClaim[catalogClaim] !== owner) { + throw new InstancePlanError( + 'INVALID_EXPANSION_OWNER', + `Check "${owner}" must be the sole emitter of expanded claim "${catalogClaim}"` + ); + } + const itemClaim = requireNonEmptyString( + expansion.item_claim, + `checks.${owner}.expand.item_claim` + ); + if (!CLAIM_REF_PATTERN.test(itemClaim) || !authority.validatorsByClaim[itemClaim]) { + throw new InstancePlanError( + 'UNKNOWN_ITEM_CLAIM', + `Check "${owner}" references undeclared item claim "${itemClaim}"` + ); + } + if (authority.rootEmitterByClaim[itemClaim]) { + throw new InstancePlanError( + 'FORGED_CONTROLLER_ITEM_CLAIM', + `Item claim "${itemClaim}" is controller-owned and cannot have a root emitter` + ); + } + const templateName = requireNonEmptyString( + expansion.template, + `checks.${owner}.expand.template` + ); + const template = templatesByName[templateName]; + if (!template) { + throw new InstancePlanError( + 'UNKNOWN_SUBGRAPH_TEMPLATE', + `Check "${owner}" references unknown subgraph template "${templateName}"` + ); + } + if (template.input.claim !== itemClaim) { + throw new InstancePlanError( + 'ITEM_CLAIM_MISMATCH', + `Check "${owner}" item claim "${itemClaim}" does not match template input "${template.input.claim}"` + ); + } + const itemsPointer = compileJsonPointer( + expansion.items_pointer, + `checks.${owner}.expand.items_pointer` + ); + const keyPointer = compileJsonPointer( + expansion.key_pointer, + `checks.${owner}.expand.key_pointer` + ); + let coverage: CompiledExpansion['coverage']; + if (expansion.coverage !== undefined) { + if (!expansion.coverage || typeof expansion.coverage !== 'object' || Array.isArray(expansion.coverage)) { + throw new InstancePlanError('INVALID_COVERAGE_CONFIG', `Check "${owner}" coverage must be an object`); + } + const outcomeClaimRef = requireNonEmptyString( + expansion.coverage.outcome_claim, + `checks.${owner}.expand.coverage.outcome_claim` + ); + if ( + !CLAIM_REF_PATTERN.test(outcomeClaimRef) || + !authority.validatorsByClaim[outcomeClaimRef] || + outcomeClaimRef === catalogClaim || + outcomeClaimRef === itemClaim || + outcomeClaimRef === template.input.claim + ) { + throw new InstancePlanError( + 'INVALID_COVERAGE_OUTCOME_CLAIM', + `Check "${owner}" coverage outcome must be a distinct declared template claim` + ); + } + const emitterNodeKey = template.emitterByClaim[outcomeClaimRef]; + if (!emitterNodeKey || template.dependentsByNode[emitterNodeKey].length !== 0) { + throw new InstancePlanError( + 'INVALID_COVERAGE_OUTCOME_EMITTER', + `Check "${owner}" coverage outcome must have exactly one sink emitter` + ); + } + coverage = Object.freeze({ + outcomeClaimRef, + emitterNodeKey, + classPointer: compileJsonPointer( + expansion.coverage.class_pointer, + `checks.${owner}.expand.coverage.class_pointer` + ), + }); + } + const expansionSpecDigest = sha256Canonical({ + v: 1, + expansionOwnerCheck: owner, + catalogClaimRef: catalogClaim, + templateName, + templateDigest: template.templateDigest, + itemsPointer: itemsPointer.source, + keyPointer: keyPointer.source, + itemClaimRef: itemClaim, + ...(coverage + ? { + coverage: { + outcomeClaimRef: coverage.outcomeClaimRef, + classPointer: coverage.classPointer.source, + emitterNodeKey: coverage.emitterNodeKey, + }, + } + : {}), + }); + precompiled.push({ + owner, + expansion, + template, + itemsPointer, + keyPointer, + coverage, + expansionSpecDigest, + }); + } + + const graphSemanticDigest = sha256Canonical({ + v: 1, + claimTypes: authority.claimTypes, + checks: resolvedRootChecks(checks), + subgraphs: Object.fromEntries( + Object.entries(templatesByName).map(([name, template]) => [ + name, + { + input: template.input, + checks: Object.fromEntries( + template.templateNodeKeys.map(node => [node, template.nodesByKey[node].check]) + ), + }, + ]) + ), + }); + const byOwner: Record = {}; + for (const compiled of precompiled) { + const expansion = compiled.expansion; + byOwner[compiled.owner] = Object.freeze({ + expansionOwnerCheck: compiled.owner, + depth: 1, + parentTemplateName: null, + parentTemplateNodeKey: null, + catalogClaimRef: expansion.claim, + catalogValidator: authority.validatorsByClaim[expansion.claim], + templateName: expansion.template, + templateDigest: compiled.template.templateDigest, + expansionSpecDigest: compiled.expansionSpecDigest, + itemsPointer: compiled.itemsPointer, + keyPointer: compiled.keyPointer, + itemClaimRef: expansion.item_claim, + itemValidator: authority.validatorsByClaim[expansion.item_claim], + template: compiled.template, + ...(compiled.coverage ? { coverage: compiled.coverage } : {}), + graphSemanticDigest, + }); + } + + const nestedDeclarations = Object.values(templatesByName).flatMap(template => + template.templateNodeKeys + .filter(nodeKey => hasOwn(template.nodesByKey[nodeKey].check, 'expand')) + .map(nodeKey => ({ template, nodeKey, check: template.nodesByKey[nodeKey].check })) + ); + if (nestedDeclarations.length > 1) { + throw new InstancePlanError( + 'NESTED_EXPANSION_AMBIGUOUS', + 'Graph v2 C4 admits exactly one generated expansion owner' + ); + } + + const byNestedOwner: Record = {}; + if (nestedDeclarations.length === 1) { + const { template: parentTemplate, nodeKey, check } = nestedDeclarations[0]; + if (!precompiled.some(candidate => candidate.template.name === parentTemplate.name)) { + throw new InstancePlanError( + 'UNREACHABLE_NESTED_EXPANSION', + `Nested expansion owner "${parentTemplate.name}.${nodeKey}" is not in a root-expanded template` + ); + } + const expansion = check.expand; + if (!expansion || typeof expansion !== 'object' || Array.isArray(expansion)) { + throw new InstancePlanError( + 'INVALID_EXPANSION_CONFIG', + `Template check "${parentTemplate.name}.${nodeKey}" expand must be an object` + ); + } + const ownerAddress = qualifiedNestedExpansionOwner(parentTemplate.name, nodeKey); + const catalogClaim = requireNonEmptyString( + expansion.claim, + `subgraphs.${parentTemplate.name}.checks.${nodeKey}.expand.claim` + ); + if (!CLAIM_REF_PATTERN.test(catalogClaim) || !authority.validatorsByClaim[catalogClaim]) { + throw new InstancePlanError( + 'UNKNOWN_EXPANSION_CLAIM', + `Template check "${parentTemplate.name}.${nodeKey}" expands undeclared catalog claim "${catalogClaim}"` + ); + } + const matchingEmissions = (check.emits || []).filter( + emission => emission.claim === catalogClaim + ); + if ( + matchingEmissions.length !== 1 || + parentTemplate.emitterByClaim[catalogClaim] !== nodeKey + ) { + throw new InstancePlanError( + 'INVALID_EXPANSION_OWNER', + `Template check "${parentTemplate.name}.${nodeKey}" must be the sole template emitter of expanded claim "${catalogClaim}"` + ); + } + const itemClaim = requireNonEmptyString( + expansion.item_claim, + `subgraphs.${parentTemplate.name}.checks.${nodeKey}.expand.item_claim` + ); + if (!CLAIM_REF_PATTERN.test(itemClaim) || !authority.validatorsByClaim[itemClaim]) { + throw new InstancePlanError( + 'UNKNOWN_ITEM_CLAIM', + `Template check "${parentTemplate.name}.${nodeKey}" references undeclared item claim "${itemClaim}"` + ); + } + if ( + authority.rootEmitterByClaim[itemClaim] || + Object.values(templatesByName).some(candidate => candidate.emitterByClaim[itemClaim]) + ) { + throw new InstancePlanError( + 'FORGED_CONTROLLER_ITEM_CLAIM', + `Nested item claim "${itemClaim}" is controller-owned and cannot have an emitter` + ); + } + const templateName = requireNonEmptyString( + expansion.template, + `subgraphs.${parentTemplate.name}.checks.${nodeKey}.expand.template` + ); + const childTemplate = templatesByName[templateName]; + if (!childTemplate) { + throw new InstancePlanError( + 'UNKNOWN_SUBGRAPH_TEMPLATE', + `Template check "${parentTemplate.name}.${nodeKey}" references unknown subgraph template "${templateName}"` + ); + } + if ( + childTemplate.name === parentTemplate.name || + childTemplate.templateNodeKeys.some(childNodeKey => + hasOwn(childTemplate.nodesByKey[childNodeKey].check, 'expand') + ) + ) { + throw new InstancePlanError( + 'NESTED_EXPANSION_DEPTH_EXCEEDED', + 'Graph v2 C4 rejects recursive, cyclic, or depth-three expansion' + ); + } + if (childTemplate.input.claim !== itemClaim) { + throw new InstancePlanError( + 'ITEM_CLAIM_MISMATCH', + `Nested item claim "${itemClaim}" does not match template input "${childTemplate.input.claim}"` + ); + } + const itemsPointer = compileJsonPointer( + expansion.items_pointer, + `subgraphs.${parentTemplate.name}.checks.${nodeKey}.expand.items_pointer` + ); + const keyPointer = compileJsonPointer( + expansion.key_pointer, + `subgraphs.${parentTemplate.name}.checks.${nodeKey}.expand.key_pointer` + ); + const expansionSpecDigest = sha256Canonical({ + v: 1, + expansionOwnerCheck: ownerAddress, + parentTemplateName: parentTemplate.name, + parentTemplateNodeKey: nodeKey, + catalogClaimRef: catalogClaim, + templateName, + templateDigest: childTemplate.templateDigest, + itemsPointer: itemsPointer.source, + keyPointer: keyPointer.source, + itemClaimRef: itemClaim, + }); + byNestedOwner[ownerAddress] = Object.freeze({ + expansionOwnerCheck: ownerAddress, + depth: 2, + parentTemplateName: parentTemplate.name, + parentTemplateNodeKey: nodeKey, + catalogClaimRef: catalogClaim, + catalogValidator: authority.validatorsByClaim[catalogClaim], + templateName, + templateDigest: childTemplate.templateDigest, + expansionSpecDigest, + itemsPointer, + keyPointer, + itemClaimRef: itemClaim, + itemValidator: authority.validatorsByClaim[itemClaim], + template: childTemplate, + graphSemanticDigest, + }); + } + + return Object.freeze({ + active: true, + graphSemanticDigest, + byOwner: frozenRecord(byOwner), + byNestedOwner: frozenRecord(byNestedOwner), + templatesByName: frozenRecord(templatesByName), + }); +} diff --git a/src/state-machine/runner.ts b/src/state-machine/runner.ts index 1e21bb3fc..96cd86421 100644 --- a/src/state-machine/runner.ts +++ b/src/state-machine/runner.ts @@ -37,6 +37,7 @@ export class StateMachineRunner { private state: RunState; private debugServer?: DebugVisualizerServer; private hasRun = false; + private runActive = false; constructor(context: EngineContext, debugServer?: DebugVisualizerServer) { this.context = context; @@ -82,9 +83,14 @@ export class StateMachineRunner { */ async run(): Promise { this.hasRun = true; + this.runActive = true; try { // Emit initial state transition event - this.emitEvent({ type: 'StateTransition', from: 'Init', to: 'Init' }); + this.emitEvent({ + type: 'StateTransition', + from: this.state.currentState, + to: this.state.currentState, + }); // Main event loop while (!this.isTerminalState(this.state.currentState)) { @@ -124,9 +130,25 @@ export class StateMachineRunner { }; this.emitEvent({ type: 'Shutdown', error: serializedError }); throw error; + } finally { + this.runActive = false; } } + requestCatalogReconciliation(ownerCheck: string) { + if (!this.runActive) { + const error = new Error('Catalog reconciliation requires an active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + } + return this.context.journal.requestCatalogReconciliation({ + sessionId: this.context.sessionId, + ownerCheck, + }); + } + /** * Execute a specific state handler * M4: Wraps each state execution in an OTEL span for observability @@ -231,17 +253,46 @@ export class StateMachineRunner { * M4: Streams events to debug visualizer for time-travel debugging */ private emitEvent(event: EngineEvent): void { - this.state.historyLog.push(event); + // In Graph v2 claim mode the ordered journal is authoritative. Scheduling + // must be committed and projected before any mutable history, EventBus, + // telemetry, hook, or debug-view observation. + let eventForObservers = event; + if (event.type === 'CheckScheduled' && this.context.claimPlan?.active) { + if (!event.attemptId || event.fence === undefined) { + throw new Error(`Claim-mode CheckScheduled for ${event.checkId} lacks attempt authority`); + } + const scheduled = event.nodeGenerationId + ? this.context.journal.scheduleGeneratedAttempt({ + nodeGenerationId: event.nodeGenerationId, + attemptId: event.attemptId, + fence: event.fence, + }) + : event.requestId + ? this.context.journal.scheduleCatalogRequestAttempt({ + requestId: event.requestId, + attemptId: event.attemptId, + fence: event.fence, + }) + : this.context.journal.scheduleCheck({ + sessionId: this.context.sessionId, + checkId: event.checkId, + scope: event.scope, + attemptId: event.attemptId, + fence: event.fence, + }); + eventForObservers = { ...event, claimIds: scheduled.claimIds }; + } + this.state.historyLog.push(eventForObservers); // Queue events that require processing by WavePlanning - if (event.type === 'ForwardRunRequested' || event.type === 'WaveRetry') { - this.state.eventQueue.push(event); + if (eventForObservers.type === 'ForwardRunRequested' || eventForObservers.type === 'WaveRetry') { + this.state.eventQueue.push(eventForObservers); } // M4: Stream event to debug visualizer for live monitoring if (this.debugServer) { try { - this.streamEventToDebugServer(event); + this.streamEventToDebugServer(eventForObservers); } catch (_err) { // Ignore debug server errors } @@ -258,21 +309,21 @@ export class StateMachineRunner { runId: this.context.sessionId, workflowId: (this.context as any).workflowId, wave: this.state.wave, - payload: event, + payload: eventForObservers, }; void bus.emit(envelope); } } catch {} // Call onCheckComplete hook for TUI streaming updates - if (event.type === 'CheckCompleted') { + if (eventForObservers.type === 'CheckCompleted') { try { const hook = this.context.executionContext?.hooks?.onCheckComplete; if (typeof hook === 'function') { - const checkConfig = this.context.config?.checks?.[event.checkId]; + const checkConfig = this.context.config?.checks?.[eventForObservers.checkId]; hook({ - checkId: event.checkId, - result: event.result, + checkId: eventForObservers.checkId, + result: eventForObservers.result, checkConfig: checkConfig ? { type: checkConfig.type, @@ -286,8 +337,8 @@ export class StateMachineRunner { } catch {} } - if (this.context.debug && event.type !== 'StateTransition') { - logger.debug(`[StateMachine] Event: ${event.type}`); + if (this.context.debug && eventForObservers.type !== 'StateTransition') { + logger.debug(`[StateMachine] Event: ${eventForObservers.type}`); } } diff --git a/src/state-machine/states/level-dispatch.ts b/src/state-machine/states/level-dispatch.ts index ce6234404..f9318a8fe 100644 --- a/src/state-machine/states/level-dispatch.ts +++ b/src/state-machine/states/level-dispatch.ts @@ -23,9 +23,31 @@ import type { import { logger } from '../../logger'; import type { ReviewSummary, ReviewIssue } from '../../reviewer'; import type { CheckExecutionStats } from '../../types/execution'; -import type { CheckProviderConfig } from '../../providers/check-provider.interface'; +import type { + CandidateClaimInput, + CheckProviderConfig, + ManagedRunOutcomeV1, + ManagedRunStartedReceiptV1, +} from '../../providers/check-provider.interface'; import type { CheckConfig } from '../../types/config'; -import { handleRouting, checkLoopBudget } from './routing'; +import type { + CatalogRequestAttemptStartedEvent, + CatalogRequestProjection, + GeneratedAttemptStartedEvent, + KeyedScopePath, + ManagedRunAcquisitionFailureCode, + ManagedRunBindingV1, + ManagedRunCleanupStatus, + ManagedRunFailureCode, + NodeGenerationProjection, +} from '../graph/instance-kernel'; +import { + applyManagedRoutingEffects, + evaluateManagedRouting, + handleRouting, + checkLoopBudget, + type ManagedRoutingDecision, +} from './routing'; import { withActiveSpan, setSpanAttributes, @@ -40,8 +62,48 @@ import { resolveWorkflowInputs } from '../context/workflow-inputs'; import { executeWithSandboxRouting } from '../dispatch/sandbox-routing'; import { applyPolicyGate } from '../dispatch/policy-gate'; import { getDebounceManager } from '../dispatch/debounce-manager'; +import { + armManagedRunDeadline, + normalizeManagedRunCancelReceipt, + normalizeManagedRunCleanupReceipt, + normalizeManagedRunOutcome, + normalizeManagedRunStartedReceipt, + normalizeManagedRunTimeout, + snapshotManagedRun, + snapshotManagedRunStartRequest, + ManagedRunProtocolError, + type ManagedRunDeadlineSettlement, + type ManagedRunSnapshot, +} from '../dispatch/managed-run'; import { createExtendedLiquid } from '../../liquid-extensions'; +type GeneratedTerminalPhase = + | 'undecided' + | 'legacy' + | 'managed_pre_acquisition' + | 'managed_acquired' + | 'terminal'; + +interface GeneratedAttemptTerminalLatch { + phase: GeneratedTerminalPhase; + terminalizeFailure?: (code: ManagedRunFailureCode) => Promise; + readonly markHaltApplied: () => void; +} + +type DynamicExecution = + | { + readonly kind: 'generated'; + readonly attempt: GeneratedAttemptStartedEvent; + readonly checkConfig: CheckConfig; + readonly claims: Readonly>; + readonly terminalLatch: GeneratedAttemptTerminalLatch; + } + | { + readonly kind: 'catalog-request'; + readonly attempt: CatalogRequestAttemptStartedEvent; + readonly checkConfig: CheckConfig; + }; + function isEngineerCheck(checkId: string): boolean { return checkId === 'engineer-task'; } @@ -76,6 +138,238 @@ function stopCheckProgressTelemetry(timer: ReturnType | null if (timer) clearInterval(timer); } +interface ManagedProviderSettlement { + readonly result?: ReviewSummary; + readonly cleanupStatus: ManagedRunCleanupStatus; + readonly failureCode?: ManagedRunFailureCode; +} + +type DeadlineSignal = + | { readonly kind: 'deadline'; readonly settlement: ManagedRunDeadlineSettlement } + | { readonly kind: 'deadline_callback_failed' }; + +// Capture the managed controller's Promise intrinsics at module load. Managed +// coordination never performs a late provider/prototype/global lookup. +const ManagedControllerPromise = Promise; +const managedPromiseThen = Promise.prototype.then; + +function thenManagedPromise( + promise: Promise, + onFulfilled?: (value: T) => Fulfilled | PromiseLike, + onRejected?: (reason: unknown) => Rejected | PromiseLike +): Promise { + return Reflect.apply(managedPromiseThen, promise, [onFulfilled, onRejected]) as Promise< + Fulfilled | Rejected + >; +} + +function raceManagedPromises(promises: readonly Promise[]): Promise { + return new ManagedControllerPromise((resolve, reject) => { + for (const promise of promises) { + Reflect.apply(managedPromiseThen, promise, [resolve, reject]); + } + }); +} + +function resolveManagedPromise(): Promise { + return new ManagedControllerPromise(resolve => resolve()); +} + +function settled(promise: Promise): Promise> { + return thenManagedPromise( + promise, + value => ({ status: 'fulfilled' as const, value }), + reason => ({ status: 'rejected' as const, reason }) + ); +} + +function protocolFailure( + error: unknown, + fallback: ManagedRunFailureCode +): ManagedRunFailureCode { + return error instanceof ManagedRunProtocolError ? error.code : fallback; +} + +function validateManagedCleanup( + snapshot: ManagedRunSnapshot, + close: PromiseSettledResult +): { cleanupStatus: ManagedRunCleanupStatus; failureCode?: ManagedRunFailureCode } { + if (close.status === 'rejected') { + return { + cleanupStatus: 'unverified', + failureCode: 'MANAGED_CLOSE_FAILED', + }; + } + try { + normalizeManagedRunCleanupReceipt(close.value, snapshot.binding); + return { cleanupStatus: 'clean' }; + } catch (error) { + return { + cleanupStatus: 'unverified', + failureCode: protocolFailure(error, 'MANAGED_CLEANUP_RECEIPT_INVALID'), + }; + } +} + +function validateManagedCancel( + snapshot: ManagedRunSnapshot, + cancel: PromiseSettledResult +): ManagedRunFailureCode | undefined { + if (cancel.status === 'rejected') { + return 'MANAGED_CANCEL_FAILED'; + } + try { + normalizeManagedRunCancelReceipt(cancel.value, snapshot.binding); + return undefined; + } catch (error) { + return protocolFailure(error, 'MANAGED_CANCEL_RECEIPT_INVALID'); + } +} + +async function runManagedProvider(input: { + readonly snapshot: ManagedRunSnapshot; + readonly timeoutMs: number; + readonly onStarted: () => void; + readonly onStartedObserved: () => void; + readonly onCancelRequested: () => void; +}): Promise { + let deadline: ReturnType | undefined; + try { + const activeDeadline = armManagedRunDeadline({ + snapshot: input.snapshot, + timeoutMs: normalizeManagedRunTimeout(input.timeoutMs), + onCancelRequested: input.onCancelRequested, + }); + deadline = activeDeadline; + const deadlineSignal: Promise = thenManagedPromise( + activeDeadline.fired, + settlement => ({ kind: 'deadline' as const, settlement }), + () => ({ kind: 'deadline_callback_failed' as const }) + ); + + const finishDeadline = async ( + signal: DeadlineSignal, + ordinaryClose?: Promise> + ): Promise => { + const close = signal.kind === 'deadline' + ? signal.settlement.close + : await (ordinaryClose || settled(input.snapshot.closeOnce())); + const cleanup = validateManagedCleanup(input.snapshot, close); + if (cleanup.failureCode) return cleanup; + if (signal.kind === 'deadline') { + if (!signal.settlement.cancelRequested || !signal.settlement.cancel) { + return { + cleanupStatus: cleanup.cleanupStatus, + failureCode: 'MANAGED_POST_PROVIDER_FAILED', + }; + } + const cancelFailure = validateManagedCancel(input.snapshot, signal.settlement.cancel); + if (cancelFailure) { + return { cleanupStatus: cleanup.cleanupStatus, failureCode: cancelFailure }; + } + return { + cleanupStatus: cleanup.cleanupStatus, + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }; + } + return { + cleanupStatus: cleanup.cleanupStatus, + failureCode: 'MANAGED_POST_PROVIDER_FAILED', + }; + }; + + const finishOrdinary = async ( + baseFailure: ManagedRunFailureCode | undefined, + result?: ReviewSummary + ): Promise => { + const close = settled(input.snapshot.closeOnce()); + const winner = await raceManagedPromises< + | { readonly kind: 'close'; readonly value: PromiseSettledResult } + | DeadlineSignal + >([ + thenManagedPromise(close, value => ({ kind: 'close' as const, value })), + deadlineSignal, + ]); + if (winner.kind !== 'close') return finishDeadline(winner, close); + if (activeDeadline.didFire()) return finishDeadline(await deadlineSignal, close); + + activeDeadline.clear(); + const cleanup = validateManagedCleanup(input.snapshot, winner.value); + if (cleanup.failureCode) return cleanup; + return { + ...(baseFailure ? { failureCode: baseFailure } : { result }), + cleanupStatus: cleanup.cleanupStatus, + }; + }; + + const started = await raceManagedPromises< + | { + readonly kind: 'started'; + readonly value: PromiseSettledResult; + } + | DeadlineSignal + >([ + thenManagedPromise( + settled(input.snapshot.started), + value => ({ kind: 'started' as const, value }) + ), + deadlineSignal, + ]); + if (started.kind !== 'started') return finishDeadline(started); + if (activeDeadline.didFire()) return finishDeadline(await deadlineSignal); + if (started.value.status === 'rejected') { + return finishOrdinary('MANAGED_STARTED_RECEIPT_INVALID'); + } + try { + normalizeManagedRunStartedReceipt(started.value.value, input.snapshot.binding); + input.onStarted(); + } catch (error) { + return finishOrdinary(protocolFailure(error, 'MANAGED_STARTED_RECEIPT_INVALID')); + } + try { + input.onStartedObserved(); + } catch {} + + const outcome = await raceManagedPromises< + | { + readonly kind: 'outcome'; + readonly value: PromiseSettledResult; + } + | DeadlineSignal + >([ + thenManagedPromise( + settled(input.snapshot.outcome), + value => ({ kind: 'outcome' as const, value }) + ), + deadlineSignal, + ]); + if (outcome.kind !== 'outcome') return finishDeadline(outcome); + if (activeDeadline.didFire()) return finishDeadline(await deadlineSignal); + if (outcome.value.status === 'rejected') { + return finishOrdinary('MANAGED_OUTCOME_FAILED'); + } + try { + const normalized = normalizeManagedRunOutcome(outcome.value.value, input.snapshot.binding); + if (normalized.kind === 'failed') return finishOrdinary('MANAGED_OUTCOME_FAILED'); + return finishOrdinary(undefined, normalized.summary); + } catch (error) { + return finishOrdinary(protocolFailure(error, 'MANAGED_OUTCOME_RECEIPT_INVALID')); + } + } catch { + deadline?.clear(); + const cleanup = validateManagedCleanup( + input.snapshot, + await settled(input.snapshot.closeOnce()) + ); + return cleanup.failureCode + ? cleanup + : { + cleanupStatus: cleanup.cleanupStatus, + failureCode: 'MANAGED_POST_PROVIDER_FAILED', + }; + } +} + /** * Render Liquid template expressions in 'with' arguments. * This is used to process args from on_success.run with directives. @@ -203,6 +497,19 @@ function buildOutputHistoryFromJournal(context: EngineContext): Map> +): Map { + const results = new Map(); + for (const claim of Object.values(claims)) { + const summary: ReviewSummary = { issues: [], output: claim.payload }; + Object.freeze(summary.issues); + Object.freeze(summary); + results.set(claim.producerCheckId, summary); + } + return results; +} + /** * Evaluate 'if' condition for a check * @@ -214,7 +521,8 @@ async function evaluateIfCondition( checkId: string, checkConfig: CheckConfig, context: EngineContext, - state: RunState + state: RunState, + exactPreviousResults?: ReadonlyMap ): Promise { const ifExpression = checkConfig.if; if (!ifExpression) { @@ -253,7 +561,9 @@ async function evaluateIfCondition( // avoid wrongly skipping top-level prompts like 'ask'. const useGlobalOutputs = hasDeps || (useGlobalOutputsFlag && waveKind === 'forward'); - if (useGlobalOutputs) { + if (exactPreviousResults) { + for (const [key, result] of exactPreviousResults) previousResults.set(key, result); + } else if (useGlobalOutputs) { // Forward-run wave: allow guards to consult latest outputs from the entire // journal so follow-up steps (e.g., post-verified after run-review) can // see the outputs produced in the prior wave that scheduled this forward-run. @@ -343,6 +653,11 @@ export async function handleLevelDispatch( transition: (newState: EngineState) => void, emitEvent: (event: EngineEvent) => void ): Promise { + if (context.claimPlan?.active) { + await handleClaimReadyDispatch(context, state, transition, emitEvent); + return; + } + // Pop next level from queue const level = state.levelQueue.shift(); @@ -433,6 +748,223 @@ export async function handleLevelDispatch( } } +/** + * C1 claim-mode scheduler. It drains the current wave into one ready-data queue + * so an exact committed claim can release its consumer without a level barrier. + * Claim truth remains exclusively in ExecutionJournal's projection. + */ +async function handleClaimReadyDispatch( + context: EngineContext, + state: RunState, + transition: (newState: EngineState) => void, + emitEvent: (event: EngineEvent) => void +): Promise { + const queued = state.levelQueue.flatMap(level => level.parallel); + state.levelQueue = []; + const pending = new Set(queued); + const running = new Map>(); + let managedHaltApplied = false; + const maxParallelism = context.maxParallelism || 10; + const failed = ((state as any).failedChecks ||= new Set()) as Set; + + emitEvent({ type: 'LevelReady', level: { level: 0, parallel: queued }, wave: state.wave }); + + const isReady = (checkId: string): boolean => { + const plan = context.claimPlan!; + const consumes = plan.consumptionsByCheck[checkId] || []; + if (!context.journal.isCheckReady(checkId)) return false; + + const consumedEmitters = new Set(consumes.map(input => plan.emitterByClaim[input.claim])); + for (const dependency of plan.effectiveDependenciesByCheck[checkId] || []) { + if (consumedEmitters.has(dependency)) continue; + if (!state.completedChecks.has(dependency)) return false; + if (failed.has(dependency) && !context.config.checks?.[dependency]?.continue_on_failure) { + return false; + } + } + return true; + }; + + const launch = (checkId: string): void => { + pending.delete(checkId); + const startedAt = Date.now(); + const task = (async () => { + try { + const result = await executeSingleCheck(checkId, context, state, emitEvent, transition); + updateStats([{ checkId, result, duration: Date.now() - startedAt }], state); + if (hasFatalIssues(result)) failed.add(checkId); + if (context.failFast && hasFatalIssues(result)) state.flags.failFastTriggered = true; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + failed.add(checkId); + updateStats( + [{ checkId, result: { issues: [] }, error: err, duration: Date.now() - startedAt }], + state + ); + if (context.failFast) state.flags.failFastTriggered = true; + } finally { + state.completedChecks.add(checkId); + running.delete(checkId); + } + })(); + running.set(checkId, task); + }; + + const launchGenerated = (generation: NodeGenerationProjection): void => { + const attempt = context.journal.startGeneratedAttempt(generation.nodeGenerationId); + const execution = context.journal.getGeneratedExecution(generation.nodeGenerationId); + const key = generation.nodeGenerationId; + const startedAt = Date.now(); + const generatedState: RunState = { + ...state, + levelQueue: [], + eventQueue: [], + activeDispatches: new Map(), + completedChecks: new Set(), + flags: { ...state.flags }, + stats: new Map(), + forwardRunGuards: new Set(), + currentLevelChecks: new Set(), + pendingRunScopes: new Map(), + pendingRunArgs: new Map(), + allowedFailedDeps: new Map(), + }; + (generatedState as any).failedChecks = new Set(); + const terminalLatch: GeneratedAttemptTerminalLatch = { + phase: 'undecided', + markHaltApplied: () => { + managedHaltApplied = true; + }, + }; + const task = (async () => { + try { + const result = await executeSingleCheck( + generation.checkId, context, generatedState, emitEvent, transition, + generation.scope, + { + kind: 'generated', + attempt, + checkConfig: execution.node.check, + claims: execution.claims, + terminalLatch, + } + ); + updateStats([{ checkId: key, result, duration: Date.now() - startedAt }], state); + } catch (error) { + updateStats([{ checkId: key, result: { issues: [] }, + error: error instanceof Error ? error : new Error(String(error)), duration: Date.now() - startedAt }], state); + if (terminalLatch.phase === 'managed_acquired' && terminalLatch.terminalizeFailure) { + await terminalLatch.terminalizeFailure('MANAGED_POST_PROVIDER_FAILED'); + } else if (terminalLatch.phase !== 'terminal') { + const projected = context.journal.getInstanceProjection().generationsById[ + attempt.nodeGenerationId + ]; + if ( + projected?.status === 'running' && + projected.attemptId === attempt.attemptId && + projected.fence === attempt.fence + ) { + context.journal.failGeneratedAttempt(attempt, 'PROVIDER_EXECUTION_FAILED'); + } + } + } finally { running.delete(key); } + })(); + running.set(key, task); + }; + + const launchCatalogRequest = (request: CatalogRequestProjection): void => { + const checkConfig = context.config.checks?.[request.expansionOwnerCheck]; + if (!checkConfig) { + throw new Error(`Check configuration not found: ${request.expansionOwnerCheck}`); + } + const attempt = context.journal.startCatalogRequestAttempt(request.requestId); + const key = request.requestId; + const startedAt = Date.now(); + const task = (async () => { + try { + await executeSingleCheck(request.expansionOwnerCheck, context, state, emitEvent, transition, + [], { kind: 'catalog-request', attempt, + checkConfig }); + } catch (error) { + updateStats([{ checkId: request.expansionOwnerCheck, result: { issues: [] }, + error: error instanceof Error ? error : new Error(String(error)), + duration: Date.now() - startedAt }], state); + const projected = context.journal.getInstanceProjection().requestsById[attempt.requestId]; + if ( + projected?.status === 'running' && + projected.attemptId === attempt.attemptId && + projected.fence === attempt.fence + ) { + context.journal.failAttempt({ + sessionId: attempt.sessionId, + checkId: attempt.checkId, + scope: attempt.scope, + attemptId: attempt.attemptId, + fence: attempt.fence, + reason: 'PROVIDER_EXECUTION_FAILED', + }); + } + } finally { running.delete(key); } + })(); + running.set(key, task); + }; + + while (true) { + let launched = false; + if (!state.flags.failFastTriggered && !managedHaltApplied) { + for (const generation of context.journal.queryReadyWork()) { + if (running.size >= maxParallelism) break; + launchGenerated(generation); + launched = true; + } + for (const checkId of [...pending]) { + if (context.journal.queryReadyWork().length > 0) break; + if (running.size >= maxParallelism) break; + if (!isReady(checkId)) continue; + launch(checkId); + launched = true; + } + } + + if (running.size > 0) { + await raceManagedPromises([...running.values()]); + continue; + } + + if (managedHaltApplied) break; + + if (context.journal.queryReadyWork().length > 0) continue; + + if (!launched && pending.size > 0) { + // No provider is running and no exact data can make progress. Mark the + // blocked consumers as dependency failures without scheduling them. + for (const checkId of pending) { + const result: ReviewSummary = { issues: [] }; + try { + Object.defineProperty(result, '__skipped', { + value: 'dependency_failed', + enumerable: false, + }); + } catch {} + state.completedChecks.add(checkId); + failed.add(checkId); + updateStats([{ checkId, result }], state); + } + pending.clear(); + continue; + } + const request = context.journal.getOldestPendingCatalogRequest(); + if (request && context.journal.queryReadyWork().length === 0) { + launchCatalogRequest(request); + continue; + } + break; + } + + emitEvent({ type: 'LevelDepleted', level: 0, wave: state.wave }); + transition(state.currentState === 'Error' ? 'Error' : 'WavePlanning'); +} + /** * Group checks by session provider to enforce sequential execution */ @@ -1915,14 +2447,61 @@ async function executeSingleCheck( state: RunState, emitEvent: (event: EngineEvent) => void, transition: (newState: EngineState) => void, - scopeOverride?: Array<{ check: string; index: number }> + scopeOverride?: Array<{ check: string; index: number }> | KeyedScopePath, + dynamic?: DynamicExecution ): Promise { // Check if this check depends on a forEach parent - const checkConfig = context.config.checks?.[checkId]; + const checkConfig = dynamic?.checkConfig || context.config.checks?.[checkId]; + const dynamicScope = (scopeOverride || []) as unknown as Array<{ + check: string; + index: number; + }>; + const exactClaims = + dynamic?.kind === 'generated' ? dynamic.claims : Object.freeze({}); + const exactControlResults = + dynamic?.kind === 'generated' ? buildExactClaimResults(exactClaims) : undefined; + + if (dynamic) { + emitEvent({ + type: 'CheckScheduled', + checkId, + scope: dynamicScope, + attemptId: dynamic.attempt.attemptId, + fence: dynamic.attempt.fence, + ...(dynamic.kind === 'generated' + ? { + nodeInstanceId: dynamic.attempt.nodeInstanceId, + nodeGenerationId: dynamic.attempt.nodeGenerationId, + } + : { requestId: dynamic.attempt.requestId }), + }); + } + + const failDynamicAttempt = (reason: string): void => { + if (!dynamic) return; + if (dynamic.kind === 'generated') { + context.journal.failGeneratedAttempt(dynamic.attempt, reason); + return; + } + context.journal.failAttempt({ + sessionId: context.sessionId, + checkId, + scope: dynamicScope, + attemptId: dynamic.attempt.attemptId, + fence: dynamic.attempt.fence, + reason, + }); + }; // Evaluate 'if' condition before execution if (checkConfig?.if) { - const shouldRun = await evaluateIfCondition(checkId, checkConfig, context, state); + const shouldRun = await evaluateIfCondition( + checkId, + checkConfig, + context, + state, + exactControlResults + ); if (!shouldRun) { // Log skip message at info level (visible without debug mode) @@ -1964,6 +2543,8 @@ async function executeSingleCheck( state.stats.set(checkId, stats); logger.info(`[LevelDispatch] Recorded skip stats for ${checkId}: skipReason=if_condition`); + failDynamicAttempt('IF_CONDITION_NOT_MET'); + // Store empty result in journal try { context.journal.commitEntry({ @@ -1971,7 +2552,7 @@ async function executeSingleCheck( checkId, result: emptyResult as any, event: context.event || 'manual', - scope: [], + scope: dynamic ? dynamicScope : [], }); } catch (error) { logger.warn(`[LevelDispatch] Failed to commit skipped result to journal: ${error}`); @@ -1981,7 +2562,7 @@ async function executeSingleCheck( emitEvent({ type: 'CheckCompleted', checkId, - scope: [], + scope: dynamic ? dynamicScope : [], result: emptyResult, }); @@ -2023,13 +2604,14 @@ async function executeSingleCheck( }; state.stats.set(checkId, stats); logger.info(`[LevelDispatch] Recorded skip stats for ${checkId}: skipReason=policy_denied`); + failDynamicAttempt('POLICY_DENIED'); try { context.journal.commitEntry({ sessionId: context.sessionId, checkId, result: emptyResult as any, event: context.event || 'manual', - scope: [], + scope: dynamic ? dynamicScope : [], }); } catch (error) { logger.warn(`[LevelDispatch] Failed to commit policy-denied result to journal: ${error}`); @@ -2037,14 +2619,14 @@ async function executeSingleCheck( emitEvent({ type: 'CheckCompleted', checkId, - scope: [], + scope: dynamic ? dynamicScope : [], result: emptyResult, }); return emptyResult; } } - const dependencies = checkConfig?.depends_on || []; + const dependencies = dynamic?.kind === 'generated' ? [] : checkConfig?.depends_on || []; const depList = Array.isArray(dependencies) ? dependencies : [dependencies]; // Dependency gating with continue_on_failure and OR groups ("A|B") @@ -2309,10 +2891,37 @@ async function executeSingleCheck( } // Normal execution without forEach - const scope: Array<{ check: string; index: number }> = scopeOverride || []; + const scope: Array<{ check: string; index: number }> = dynamicScope; + + const providerClaims = dynamic?.kind === 'generated' + ? exactClaims + : context.claimPlan?.active + ? context.journal.readCheckClaims(checkId) + : Object.freeze({}); + const claimAttempt = dynamic?.attempt || (context.claimPlan?.active + ? context.journal.startAttempt({ sessionId: context.sessionId, checkId, scope }) + : undefined); + let claimAttemptFinished = false; + let managedBinding: ManagedRunBindingV1 | undefined; + let managedSettlement: ManagedProviderSettlement | undefined; + let managedRoutingDecision: ManagedRoutingDecision | undefined; + let managedControllerFailure: ManagedRunFailureCode | undefined; + let managedTerminalTelemetryEmitted = false; // Emit scheduled event - emitEvent({ type: 'CheckScheduled', checkId, scope }); + if (!dynamic) { + emitEvent({ + type: 'CheckScheduled', + checkId, + scope, + ...(claimAttempt + ? { + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + } + : {}), + }); + } // Track start time for duration calculation const startTime = Date.now(); @@ -2331,7 +2940,6 @@ async function executeSingleCheck( try { // Get check configuration - const checkConfig = context.config.checks?.[checkId]; if (!checkConfig) { throw new Error(`Check configuration not found: ${checkId}`); } @@ -2355,7 +2963,9 @@ async function executeSingleCheck( const provider = providerRegistry.getProviderOrThrow(providerType); // Build output history for template rendering - const outputHistory = buildOutputHistoryFromJournal(context); + const outputHistory = dynamic?.kind === 'generated' + ? new Map() + : buildOutputHistoryFromJournal(context); // Resolve workflow inputs from config or context (centralized logic) const workflowInputs = resolveWorkflowInputs(checkConfig, context); @@ -2454,7 +3064,9 @@ async function executeSingleCheck( } catch {} // Build dependency results - const dependencyResults = buildDependencyResults(checkId, checkConfig, context, state); + const dependencyResults = dynamic?.kind === 'generated' + ? buildExactClaimResults(exactClaims) + : buildDependencyResults(checkId, checkConfig, context, state); // Build PR info (use real prInfo from context if available, otherwise use defaults) const prInfo: any = context.prInfo || { @@ -2479,13 +3091,28 @@ async function executeSingleCheck( const renderedArgs = rawPendingArgs ? await renderTemplateArgs(rawPendingArgs, depResultsObj, context) : undefined; + const inheritedExecutionContext = (() => { + if (dynamic?.kind !== 'generated') return context.executionContext; + const inherited = { ...(context.executionContext || {}) } as Record; + delete inherited._parentContext; + delete inherited._parentState; + delete inherited.journal; + return inherited; + })(); const executionContext = { - ...context.executionContext, + ...inheritedExecutionContext, _engineMode: context.mode, - _parentContext: context, - _parentState: state, + ...(dynamic?.kind === 'generated' + ? {} + : { _parentContext: context, _parentState: state }), // Make checks metadata available to providers that want it checksMeta, + ...(context.claimPlan?.active ? { claims: providerClaims } : {}), + ...(dynamic?.kind === 'generated' ? { + nodeInstanceId: dynamic.attempt.nodeInstanceId, + nodeGenerationId: dynamic.attempt.nodeGenerationId, + scope: Object.freeze(scope.map((part: any) => Object.freeze({ ...part }))), + } : {}), // Inject args from on_success.run with directives (merged with existing args) args: renderedArgs ? { ...((context.executionContext as any)?.args || {}), ...renderedArgs } @@ -2551,6 +3178,18 @@ async function executeSingleCheck( }); } catch {} try { + if (claimAttempt && !claimAttemptFinished) { + if (dynamic?.kind === 'generated') context.journal.failGeneratedAttempt(dynamic.attempt, 'ASSUMPTION_NOT_MET'); + else context.journal.failAttempt({ + sessionId: context.sessionId, + checkId, + scope, + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + reason: 'ASSUMPTION_NOT_MET', + }); + claimAttemptFinished = true; + } context.journal.commitEntry({ sessionId: context.sessionId, checkId, @@ -2565,113 +3204,237 @@ async function executeSingleCheck( } } - // Emit provider telemetry - try { - emitNdjsonFallback('visor.provider', { - 'visor.check.id': checkId, - 'visor.provider.type': providerType, - }); - } catch {} + const managedGenerated = dynamic?.kind === 'generated' && typeof provider.startManaged === 'function'; + let result: ReviewSummary; + + if (managedGenerated) { + const terminalLatch = dynamic.terminalLatch; + terminalLatch.phase = 'managed_pre_acquisition'; + const binding = context.journal.deriveManagedRunBinding(dynamic.attempt); + const selectedManagedTimeout = checkConfig.timeout || checkConfig.ai?.timeout || 1800000; + const managedTimeoutMs = normalizeManagedRunTimeout(selectedManagedTimeout); + managedBinding = binding; + + const failAcquisition = (failureCode: ManagedRunAcquisitionFailureCode): never => { + context.journal.failManagedRunAcquisition({ + attempt: dynamic.attempt, + binding, + failureCode, + }); + claimAttemptFinished = true; + terminalLatch.phase = 'terminal'; + throw new Error(failureCode); + }; - // Execute provider with telemetry (sandbox routing if configured) - emitImmediateSpan(`visor.check.${checkId}.started`, { - 'visor.check.id': checkId, - 'visor.check.type': providerType, - session_id: context.sessionId, - wave: state.wave, - }); - if (isEngineerCheck(checkId)) { - emitImmediateSpan('visor.engineer.started', { - 'visor.check.id': checkId, - 'visor.check.type': providerType, - }); - } - const progressTimer = startCheckProgressTelemetry(checkId, providerType, { - session_id: context.sessionId, - wave: state.wave, - }); - let result; - try { - result = await withActiveSpan( - `visor.check.${checkId}`, - { + const sandboxFailure = ((): ManagedRunAcquisitionFailureCode | undefined => { + try { + const selectedSandbox = context.sandboxManager?.resolveSandbox( + checkConfig.sandbox || (checkConfig as { workflowInputs?: { sandbox?: string } }).workflowInputs?.sandbox, + context.config.sandbox as string | undefined + ); + return selectedSandbox ? 'MANAGED_SANDBOX_UNSUPPORTED' : undefined; + } catch { + return 'MANAGED_SANDBOX_UNSUPPORTED'; + } + })(); + if (sandboxFailure) failAcquisition(sandboxFailure); + if (checkConfig.debounce && checkConfig.debounce > 0) { + failAcquisition('MANAGED_DEBOUNCE_UNSUPPORTED'); + } + + const snapshot = (() => { + try { + return snapshotManagedRun( + () => + provider.startManaged!(snapshotManagedRunStartRequest({ + prInfo, + checkConfig: providerConfig, + dependencyResults, + executionContext, + binding, + })), + binding + ); + } catch (error) { + const code = protocolFailure(error, 'MANAGED_HANDLE_INVALID'); + return failAcquisition( + code === 'MANAGED_START_FAILED' || + code === 'MANAGED_BINDING_MISMATCH' || + code === 'MANAGED_HANDLE_INVALID' + ? code + : 'MANAGED_HANDLE_INVALID' + ); + } + })(); + + context.journal.recordManagedRunAcquired(binding); + terminalLatch.phase = 'managed_acquired'; + try { + emitNdjsonFallback('visor.provider', { 'visor.check.id': checkId, - 'visor.check.type': providerType, - session_id: context.sessionId, - wave: state.wave, + 'visor.provider.type': providerType, + }); + } catch {} + + const settlementPromise = runManagedProvider({ + snapshot, + timeoutMs: managedTimeoutMs, + onStarted: () => { + context.journal.recordManagedRunStarted(binding); }, - async span => { - // Debounce support: coalesce rapid invocations of the same step - if (checkConfig.debounce && checkConfig.debounce > 0) { - const debounceKey = checkConfig.debounce_key || checkId; - const debounceResult = await getDebounceManager().enqueue( - debounceKey, - checkConfig.debounce, - async () => { - const r = await executeWithSandboxRouting( - checkId, - checkConfig, - context, - prInfo, - dependencyResults, - checkConfig.timeout || checkConfig.ai?.timeout || 1800000, - () => - provider.execute(prInfo, providerConfig, dependencyResults, executionContext) - ); - try { - captureCheckOutput(span, (r as any).output); - } catch {} - return r; - } - ); - if (debounceResult.outcome === 'debounced') { - logger.info(`[LevelDispatch] ${checkId}: debounced (superseded by later invocation)`); - return { issues: [], output: { debounced: true } }; - } - // outcome === 'executed' — return the actual result - return debounceResult.result as any; + onStartedObserved: () => { + emitImmediateSpan(`visor.check.${checkId}.started`, { + 'visor.check.id': checkId, + 'visor.check.type': providerType, + session_id: context.sessionId, + wave: state.wave, + }); + if (isEngineerCheck(checkId)) { + emitImmediateSpan('visor.engineer.started', { + 'visor.check.id': checkId, + 'visor.check.type': providerType, + }); } - const res = await executeWithSandboxRouting( - checkId, - checkConfig, - context, - prInfo, - dependencyResults, - checkConfig.timeout || checkConfig.ai?.timeout || 1800000, - () => provider.execute(prInfo, providerConfig, dependencyResults, executionContext) - ); - try { - captureCheckOutput(span, (res as any).output); - } catch {} - return res; + }, + onCancelRequested: () => { + context.journal.recordManagedRunCancelRequested(binding); + }, + }); + let terminalPromise: Promise | undefined; + terminalLatch.terminalizeFailure = requestedCode => { + if (terminalLatch.phase === 'terminal') return resolveManagedPromise(); + if (!terminalPromise) { + terminalPromise = (async () => { + const observed = await settlementPromise; + context.journal.failManagedGeneratedAttempt({ + attempt: dynamic.attempt, + binding, + cleanupStatus: observed.cleanupStatus, + failureCode: observed.failureCode || requestedCode, + }); + claimAttemptFinished = true; + terminalLatch.phase = 'terminal'; + })(); } - ); - emitImmediateSpan(`visor.check.${checkId}.completed`, { + return terminalPromise; + }; + + managedSettlement = await settlementPromise; + if (managedSettlement.failureCode || !managedSettlement.result) { + const code = managedSettlement.failureCode || 'MANAGED_POST_PROVIDER_FAILED'; + await terminalLatch.terminalizeFailure(code); + throw new Error(code); + } + result = managedSettlement.result; + } else { + if (dynamic?.kind === 'generated') dynamic.terminalLatch.phase = 'legacy'; + + // Emit provider telemetry + try { + emitNdjsonFallback('visor.provider', { + 'visor.check.id': checkId, + 'visor.provider.type': providerType, + }); + } catch {} + + // Execute provider with telemetry (sandbox routing if configured) + emitImmediateSpan(`visor.check.${checkId}.started`, { 'visor.check.id': checkId, 'visor.check.type': providerType, + session_id: context.sessionId, + wave: state.wave, }); if (isEngineerCheck(checkId)) { - emitImmediateSpan('visor.engineer.completed', { + emitImmediateSpan('visor.engineer.started', { 'visor.check.id': checkId, 'visor.check.type': providerType, }); } - } catch (error) { - emitImmediateSpan(`visor.check.${checkId}.failed`, { - 'visor.check.id': checkId, - 'visor.check.type': providerType, - error: error instanceof Error ? error.message : String(error), + const progressTimer = startCheckProgressTelemetry(checkId, providerType, { + session_id: context.sessionId, + wave: state.wave, }); - if (isEngineerCheck(checkId)) { - emitImmediateSpan('visor.engineer.failed', { + try { + result = await withActiveSpan( + `visor.check.${checkId}`, + { + 'visor.check.id': checkId, + 'visor.check.type': providerType, + session_id: context.sessionId, + wave: state.wave, + }, + async span => { + // Debounce support: coalesce rapid invocations of the same step + if (checkConfig.debounce && checkConfig.debounce > 0) { + const debounceKey = checkConfig.debounce_key || checkId; + const debounceResult = await getDebounceManager().enqueue( + debounceKey, + checkConfig.debounce, + async () => { + const r = await executeWithSandboxRouting( + checkId, + checkConfig, + context, + prInfo, + dependencyResults, + checkConfig.timeout || checkConfig.ai?.timeout || 1800000, + () => + provider.execute(prInfo, providerConfig, dependencyResults, executionContext) + ); + try { + captureCheckOutput(span, (r as any).output); + } catch {} + return r; + } + ); + if (debounceResult.outcome === 'debounced') { + logger.info(`[LevelDispatch] ${checkId}: debounced (superseded by later invocation)`); + return { issues: [], output: { debounced: true } }; + } + return debounceResult.result as ReviewSummary; + } + const res = await executeWithSandboxRouting( + checkId, + checkConfig, + context, + prInfo, + dependencyResults, + checkConfig.timeout || checkConfig.ai?.timeout || 1800000, + () => provider.execute(prInfo, providerConfig, dependencyResults, executionContext) + ); + try { + captureCheckOutput(span, (res as any).output); + } catch {} + return res; + } + ); + emitImmediateSpan(`visor.check.${checkId}.completed`, { + 'visor.check.id': checkId, + 'visor.check.type': providerType, + }); + if (isEngineerCheck(checkId)) { + emitImmediateSpan('visor.engineer.completed', { + 'visor.check.id': checkId, + 'visor.check.type': providerType, + }); + } + } catch (error) { + emitImmediateSpan(`visor.check.${checkId}.failed`, { 'visor.check.id': checkId, 'visor.check.type': providerType, error: error instanceof Error ? error.message : String(error), }); + if (isEngineerCheck(checkId)) { + emitImmediateSpan('visor.engineer.failed', { + 'visor.check.id': checkId, + 'visor.check.type': providerType, + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } finally { + stopCheckProgressTelemetry(progressTimer); } - throw error; - } finally { - stopCheckProgressTelemetry(progressTimer); } // Special case: human-input style checks that intentionally pause the run @@ -2755,7 +3518,10 @@ async function executeSingleCheck( const exprs = Array.isArray(guaranteeExpr) ? guaranteeExpr : [guaranteeExpr]; for (const ex of exprs) { const holds = await evaluator.evaluateIfCondition(checkId, ex, { - previousResults: dependencyResults as any, + previousResults: + dynamic?.kind === 'generated' + ? buildExactClaimResults(exactClaims) + : dependencyResults, event: context.event || 'manual', output: enrichedResult.output, } as any); @@ -2814,57 +3580,70 @@ async function executeSingleCheck( } (state as any).failedChecks.add(checkId); } catch {} - // Early exit: persist result and stop further processing to avoid - // undefined-state follow-on work (routing/template/per-item commits). - try { - // Record completion BEFORE storing - state.completedChecks.add(checkId); - const currentWaveCompletions = (state as any).currentWaveCompletions as - | Set - | undefined; - if (currentWaveCompletions) currentWaveCompletions.add(checkId); - - // Update aggregated stats for forEach parent (failed run, 0 outputs) - const existing = state.stats.get(checkId); - const aggStats: CheckExecutionStats = existing || { - checkName: checkId, - totalRuns: 0, - successfulRuns: 0, - failedRuns: 0, - skippedRuns: 0, - skipped: false, - totalDuration: 0, - issuesFound: 0, - issuesBySeverity: { critical: 0, error: 0, warning: 0, info: 0 }, - }; - aggStats.totalRuns++; - aggStats.failedRuns++; - aggStats.outputsProduced = 0; - state.stats.set(checkId, aggStats); + if (!managedBinding) { + if (claimAttempt && !claimAttemptFinished) { + if (dynamic?.kind === 'generated') { + context.journal.failGeneratedAttempt(dynamic.attempt, 'UNDEFINED_RESULT'); + } else { + context.journal.failAttempt({ + sessionId: context.sessionId, + checkId, + scope, + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + reason: 'UNDEFINED_RESULT', + }); + } + claimAttemptFinished = true; + } + // Preserve the legacy early exit. Managed generated work continues + // into routing so its fatal summary reaches the one managed terminalizer. + try { + state.completedChecks.add(checkId); + const currentWaveCompletions = (state as any).currentWaveCompletions as + | Set + | undefined; + if (currentWaveCompletions) currentWaveCompletions.add(checkId); + + const existing = state.stats.get(checkId); + const aggStats: CheckExecutionStats = existing || { + checkName: checkId, + totalRuns: 0, + successfulRuns: 0, + failedRuns: 0, + skippedRuns: 0, + skipped: false, + totalDuration: 0, + issuesFound: 0, + issuesBySeverity: { critical: 0, error: 0, warning: 0, info: 0 }, + }; + aggStats.totalRuns++; + aggStats.failedRuns++; + aggStats.outputsProduced = 0; + state.stats.set(checkId, aggStats); - // Store in journal - context.journal.commitEntry({ - sessionId: context.sessionId, + context.journal.commitEntry({ + sessionId: context.sessionId, + checkId, + result: enrichedResult as any, + event: context.event || 'manual', + scope: [], + }); + } catch (err) { + logger.warn(`[LevelDispatch] Failed to persist undefined forEach result: ${err}`); + } + + try { + state.activeDispatches.delete(checkId); + } catch {} + emitEvent({ + type: 'CheckCompleted', checkId, - result: enrichedResult as any, - event: context.event || 'manual', scope: [], + result: enrichedResult, }); - } catch (err) { - logger.warn(`[LevelDispatch] Failed to persist undefined forEach result: ${err}`); + return enrichedResult as ReviewSummary; } - - // Clear active dispatch and emit completion event - try { - state.activeDispatches.delete(checkId); - } catch {} - emitEvent({ - type: 'CheckCompleted', - checkId, - scope: [], - result: enrichedResult, - }); - return enrichedResult as ReviewSummary; } else if (Array.isArray(output)) { isForEach = true; forEachItems = output; @@ -2968,18 +3747,132 @@ async function executeSingleCheck( currentWaveCompletions.add(checkId); } - // Process routing (fail_if, on_success, on_fail) BEFORE storing in journal - // This allows routing errors to be included in the stored result + // Managed generated work evaluates only fail_if/failure_conditions here; + // its halt observer and transition are deferred until terminal commit. try { logger.info(`[LevelDispatch] Calling handleRouting for ${checkId}`); } catch {} - const wasHalted = await handleRouting(context, state, transition, emitEvent, { + const routingContext = { checkId, scope, result: enrichedResult, checkConfig: checkConfig as CheckConfig, success: !hasFatalIssues(enrichedResult), - }); + ...(dynamic?.kind === 'generated' + ? { exactPreviousResults: buildExactClaimResults(exactClaims) } + : {}), + }; + let wasHalted = false; + if (managedBinding) { + managedRoutingDecision = await evaluateManagedRouting(context, state, routingContext); + } else { + wasHalted = await handleRouting(context, state, transition, emitEvent, routingContext); + } + + // Terminal claim processing happens only after provider output enrichment, + // fail_if, routing, and halt/fatal determination. ExecutionJournal owns the + // immutable plan and atomically commits all emissions plus completion. + if (claimAttempt) { + if (managedBinding && dynamic?.kind === 'generated') { + managedControllerFailure = managedRoutingDecision?.haltExecution + ? 'MANAGED_HALT_EXECUTION' + : managedRoutingDecision?.failed + ? 'MANAGED_FAIL_IF' + : hasFatalIssues(enrichedResult) + ? 'MANAGED_FATAL_SUMMARY' + : undefined; + if (managedControllerFailure) { + await dynamic.terminalLatch.terminalizeFailure?.(managedControllerFailure); + if (managedRoutingDecision?.haltExecution) { + dynamic.terminalLatch.markHaltApplied(); + } + } else { + try { + context.journal.completeManagedGeneratedAttempt({ + attempt: dynamic.attempt, + binding: managedBinding, + payload: (result as { output?: unknown }).output, + }); + claimAttemptFinished = true; + dynamic.terminalLatch.phase = 'terminal'; + } catch (error) { + managedControllerFailure = 'MANAGED_CLAIM_VALIDATION_FAILED'; + await dynamic.terminalLatch.terminalizeFailure?.(managedControllerFailure); + throw error; + } + } + claimAttemptFinished = true; + + if (managedRoutingDecision) { + wasHalted = applyManagedRoutingEffects( + checkId, + managedRoutingDecision, + transition, + emitEvent + ); + } + emitImmediateSpan( + `visor.check.${checkId}.${managedControllerFailure ? 'failed' : 'completed'}`, + { + 'visor.check.id': checkId, + 'visor.check.type': checkConfig.type || 'ai', + ...(managedControllerFailure ? { error: managedControllerFailure } : {}), + } + ); + if (isEngineerCheck(checkId)) { + emitImmediateSpan( + `visor.engineer.${managedControllerFailure ? 'failed' : 'completed'}`, + { + 'visor.check.id': checkId, + 'visor.check.type': checkConfig.type || 'ai', + ...(managedControllerFailure ? { error: managedControllerFailure } : {}), + } + ); + } + managedTerminalTelemetryEmitted = true; + } else if (wasHalted || hasFatalIssues(enrichedResult)) { + if (dynamic?.kind === 'generated') context.journal.failGeneratedAttempt( + dynamic.attempt, + wasHalted ? 'HALT_EXECUTION' : 'TERMINAL_RESULT_FAILED' + ); + else context.journal.failAttempt({ + sessionId: context.sessionId, + checkId, + scope, + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + reason: wasHalted ? 'HALT_EXECUTION' : 'TERMINAL_RESULT_FAILED', + }); + claimAttemptFinished = true; + } else { + try { + if (dynamic?.kind === 'generated') { + context.journal.completeGeneratedAttempt({ attempt: dynamic.attempt, payload: (result as any).output }); + } else context.journal.completeAttempt({ + sessionId: context.sessionId, + checkId, + scope, + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + payload: (result as any).output, + }); + claimAttemptFinished = true; + } catch (error) { + const reason = (error as any)?.code || 'CLAIM_PUBLICATION_FAILED'; + if (dynamic?.kind === 'generated') context.journal.failGeneratedAttempt(dynamic.attempt, reason); + else context.journal.failAttempt({ + sessionId: context.sessionId, + checkId, + scope, + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + reason, + }); + claimAttemptFinished = true; + throw error; + } + } + } // If execution was halted by halt_execution, commit the result (with halt issue) then return if (wasHalted) { @@ -3007,6 +3900,18 @@ async function executeSingleCheck( } catch (error) { logger.warn(`[LevelDispatch] Failed to commit halt result to journal: ${error}`); } + if (managedBinding) { + emitEvent({ + type: 'CheckCompleted', + checkId, + scope, + result: { + ...enrichedResult, + output: (result as { output?: unknown }).output, + content: renderedContent || (result as { content?: string }).content, + }, + }); + } return enrichedResult; } @@ -3103,6 +4008,53 @@ async function executeSingleCheck( const err = error instanceof Error ? error : new Error(String(error)); logger.error(`[LevelDispatch] Error executing check ${checkId}: ${err.message}`); + if ( + dynamic?.kind === 'generated' && + dynamic.terminalLatch.phase === 'managed_acquired' && + dynamic.terminalLatch.terminalizeFailure + ) { + await dynamic.terminalLatch.terminalizeFailure('MANAGED_POST_PROVIDER_FAILED'); + claimAttemptFinished = true; + } else if (claimAttempt && !claimAttemptFinished && dynamic?.kind !== 'generated') { + try { + context.journal.failAttempt({ + sessionId: context.sessionId, + checkId, + scope, + attemptId: claimAttempt.attemptId, + fence: claimAttempt.fence, + reason: 'PROVIDER_EXECUTION_FAILED', + }); + claimAttemptFinished = true; + } catch {} + } else if ( + claimAttempt && + !claimAttemptFinished && + dynamic?.kind === 'generated' && + dynamic.terminalLatch.phase !== 'terminal' + ) { + try { + context.journal.failGeneratedAttempt(dynamic.attempt, 'PROVIDER_EXECUTION_FAILED'); + claimAttemptFinished = true; + } catch {} + } + + if (managedBinding && !managedTerminalTelemetryEmitted) { + emitImmediateSpan(`visor.check.${checkId}.failed`, { + 'visor.check.id': checkId, + 'visor.check.type': checkConfig?.type || 'ai', + error: err.message, + }); + if (isEngineerCheck(checkId)) { + emitImmediateSpan('visor.engineer.failed', { + 'visor.check.id': checkId, + 'visor.check.type': checkConfig?.type || 'ai', + error: err.message, + }); + } + managedTerminalTelemetryEmitted = true; + } + state.activeDispatches.delete(checkId); // Emit error event diff --git a/src/state-machine/states/routing.ts b/src/state-machine/states/routing.ts index 2ab603b99..1ef2b2925 100644 --- a/src/state-machine/states/routing.ts +++ b/src/state-machine/states/routing.ts @@ -200,6 +200,7 @@ interface RoutingContext { result: ReviewSummary; checkConfig: CheckConfig; success: boolean; // true if no fatal issues + exactPreviousResults?: ReadonlyMap; } /** @@ -247,6 +248,74 @@ type RoutingTrigger = 'on_success' | 'on_fail' | 'on_finish'; type RoutingAction = 'run' | 'goto' | 'retry'; type RoutingSource = 'run' | 'run_js' | 'goto' | 'goto_js' | 'transitions' | 'retry'; +/** + * The generated managed-run path separates semantic routing evaluation from + * effects. This keeps fail_if/failure_conditions in the controller decision, + * while Shutdown and the Error transition remain invisible until the journal + * has atomically committed the managed terminal batch. + */ +export interface ManagedRoutingDecision { + readonly failed: boolean; + readonly haltExecution: boolean; + readonly haltMessage?: string; +} + +export async function evaluateManagedRouting( + context: EngineContext, + state: RunState, + routingContext: RoutingContext +): Promise { + const { checkId, result, checkConfig } = routingContext; + const decision = await evaluateFailIf( + checkId, + result, + checkConfig, + context, + state, + routingContext.exactPreviousResults + ); + + if (decision.haltExecution) { + const haltIssue: ReviewIssue = { + file: 'system', + line: 0, + ruleId: `${checkId}_halt_execution`, + message: `Execution halted: ${decision.haltMessage || 'Critical failure condition met'}`, + severity: 'error', + category: 'logic', + }; + result.issues = [...(result.issues || []), haltIssue]; + } + + return Object.freeze({ + failed: decision.failed, + haltExecution: decision.haltExecution, + ...(decision.haltMessage ? { haltMessage: decision.haltMessage } : {}), + }); +} + +export function applyManagedRoutingEffects( + checkId: string, + decision: ManagedRoutingDecision, + transition: (newState: EngineState) => void, + emitEvent: (event: EngineEvent) => void +): boolean { + if (!decision.haltExecution) return false; + + logger.error( + `[Routing] HALTING EXECUTION due to critical failure in ${checkId}: ${decision.haltMessage}` + ); + emitEvent({ + type: 'Shutdown', + error: { + message: decision.haltMessage || `Execution halted by check ${checkId}`, + name: 'HaltExecution', + }, + }); + transition('Error'); + return true; +} + function formatScopeLabel(scope: Array<{ check: string; index: number }> | undefined): string { if (!scope || scope.length === 0) return ''; return scope.map(item => `${item.check}:${item.index}`).join('|'); @@ -291,7 +360,14 @@ export async function handleRouting( logger.info(`[Routing] Evaluating routing for check: ${checkId}, success: ${success}`); // Step 1: Evaluate fail_if and failure_conditions - const failureResult = await evaluateFailIf(checkId, result, checkConfig, context, state); + const failureResult = await evaluateFailIf( + checkId, + result, + checkConfig, + context, + state, + routingContext.exactPreviousResults + ); // Step 1.5: Check if we need to halt execution immediately if (failureResult.haltExecution) { @@ -654,7 +730,8 @@ async function evaluateFailIf( result: ReviewSummary, checkConfig: CheckConfig, context: EngineContext, - state: RunState + state: RunState, + exactPreviousResults?: ReadonlyMap ): Promise { const config = context.config; @@ -672,24 +749,28 @@ async function evaluateFailIf( // Build outputs record from state const outputsRecord: Record = {}; - for (const [key] of state.stats.entries()) { - // Try to get the actual result from context.journal if available - try { - const snapshotId = context.journal.beginSnapshot(); - const contextView = new (require('../../snapshot-store').ContextView)( - context.journal, - context.sessionId, - snapshotId, - [], - context.event - ); - const journalResult = contextView.get(key); - if (journalResult) { - outputsRecord[key] = journalResult as ReviewSummary; + if (exactPreviousResults) { + for (const [key, value] of exactPreviousResults) outputsRecord[key] = value; + } else { + for (const [key] of state.stats.entries()) { + // Try to get the actual result from context.journal if available + try { + const snapshotId = context.journal.beginSnapshot(); + const contextView = new (require('../../snapshot-store').ContextView)( + context.journal, + context.sessionId, + snapshotId, + [], + context.event + ); + const journalResult = contextView.get(key); + if (journalResult) { + outputsRecord[key] = journalResult as ReviewSummary; + } + } catch { + // Fallback to empty result + outputsRecord[key] = { issues: [] }; } - } catch { - // Fallback to empty result - outputsRecord[key] = { issues: [] }; } } diff --git a/src/types/config.ts b/src/types/config.ts index eb34674d3..073d7f8d6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -219,7 +219,8 @@ export type ConfigCheckType = | 'workflow' | 'git-checkout' | 'a2a' - | 'utcp'; + | 'utcp' + | 'proof-admit'; /** * Valid event triggers for checks @@ -501,6 +502,60 @@ export interface RateLimitConfig { initial_delay_ms?: number; } +/** A schema-bound candidate claim declaration. */ +export interface ClaimTypeConfig { + /** JSON Schema used for strict candidate publication validation. */ + schema: Record; +} + +/** Publish the raw terminal provider output as an exact claim type/version. */ +export interface ClaimEmissionConfig { + claim: string; + from: 'output'; +} + +/** Require one active candidate claim of an exact type/version. */ +export interface ClaimConsumptionConfig { + claim: string; + /** C1 root declarations require this explicitly; C2 templates default it to one. */ + cardinality?: 'one'; + /** Immutable provider-context binding used by generated template checks. */ + as?: string; +} + +/** Compile one terminal catalog claim into stable keyed template instances. */ +export interface ExpansionConfig { + /** Catalog claim emitted by this same root check. */ + claim: string; + /** Named subgraph template to instantiate for every keyed catalog item. */ + template: string; + /** RFC 6901 pointer from the catalog payload to its item array. */ + items_pointer: string; + /** RFC 6901 pointer, relative to one item, to its stable key. */ + key_pointer: string; + /** Claim published by the controller for one schema-valid item. */ + item_claim: string; + /** Optional deterministic terminal coverage projection for the selected catalog. */ + coverage?: { + /** Claim emitted exactly once by the template's terminal sink operation. */ + outcome_claim: string; + /** RFC 6901 pointer from the outcome payload to its terminal class. */ + class_pointer: string; + }; +} + +/** One externally supplied claim binding for a generated subgraph template. */ +export interface SubgraphInputConfig { + name: string; + claim: string; +} + +/** A statically compiled, one-level generated subgraph template. */ +export interface SubgraphConfig { + input: SubgraphInputConfig; + checks: Record; +} + /** * Configuration for a single check */ @@ -628,6 +683,18 @@ export interface CheckConfig { timeout?: number; /** Check IDs that this check depends on (optional). Accepts single string or array. */ depends_on?: string | string[]; + /** + * Candidate claims emitted from this check's raw terminal output. + * @minItems 1 + */ + emits?: ClaimEmissionConfig[]; + /** + * Exact candidate claims required before this check may run. + * @minItems 1 + */ + consumes?: ClaimConsumptionConfig[]; + /** Optional C2 keyed expansion owned by this root check. */ + expand?: ExpansionConfig; /** Group name for comment separation (e.g., "code-review", "pr-overview") - optional */ group?: string; /** Schema type for template rendering (e.g., "code-review", "markdown") or inline JSON schema object - optional */ @@ -1607,6 +1674,10 @@ export interface VisorConfig { steps?: Record; /** Check configurations (legacy, use 'steps' instead) - always populated after normalization */ checks?: Record; + /** Exact versioned candidate-claim schemas. Presence activates Graph v2 C1 semantics. */ + claim_types?: Record; + /** Named one-level templates used by C2 keyed expansion declarations. */ + subgraphs?: Record; /** Output configuration (optional - defaults provided) */ output?: OutputConfig; /** HTTP server configuration for receiving webhooks */ diff --git a/src/types/engine.ts b/src/types/engine.ts index 78d4df0dd..11d180d70 100644 --- a/src/types/engine.ts +++ b/src/types/engine.ts @@ -7,6 +7,7 @@ import type { ReviewSummary } from '../reviewer'; import type { CheckExecutionStats } from './execution'; import type { WorkspaceManager } from '../utils/workspace-manager'; import type { SandboxManager } from '../sandbox/sandbox-manager'; +import type { ClaimPlan } from '../state-machine/graph/claim-plan'; /** * Engine execution modes @@ -42,7 +43,18 @@ export type EngineEvent = | { type: 'WaveRequested'; wave: number } | { type: 'LevelReady'; level: ExecutionGroup; wave: number } | { type: 'LevelDepleted'; level: number; wave: number } - | { type: 'CheckScheduled'; checkId: string; scope: ScopePath } + | { + type: 'CheckScheduled'; + checkId: string; + scope: ScopePath; + attemptId?: string; + fence?: number; + /** Journal-derived exact claim IDs exposed only after authoritative scheduling. */ + claimIds?: readonly string[]; + nodeInstanceId?: string; + nodeGenerationId?: string; + requestId?: string; + } | { type: 'CheckCompleted'; checkId: string; @@ -112,6 +124,8 @@ export interface EngineContext { config: VisorConfig; dependencyGraph?: DependencyGraph; checks: Record; + /** Immutable compiled Graph v2 claim bindings, when claim mode is active. */ + claimPlan?: ClaimPlan; journal: ExecutionJournal; memory: MemoryStore; gitHubChecks?: GitHubCheckService; diff --git a/tests/engine/durable-graph-engine-continuation.test.ts b/tests/engine/durable-graph-engine-continuation.test.ts new file mode 100644 index 000000000..b21fcd2c2 --- /dev/null +++ b/tests/engine/durable-graph-engine-continuation.test.ts @@ -0,0 +1,385 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { execFileSync } from 'child_process'; +import { StateMachineExecutionEngine, type GraphCheckpointContinuationInput } from '../../src/sdk'; +import { ExecutionJournal } from '../../src/snapshot-store'; +import { compileClaimPlan } from '../../src/state-machine/graph/claim-plan'; +import { MemoryStore } from '../../src/memory-store'; +import { sha256Canonical } from '../../src/state-machine/graph/claim-kernel'; +import { OWNER, config, prInfo } from '../fixtures/durable-graph-engine-continuation-child'; + +type Artifact = { + pid: number; + checkpoint: any; + calls: any[]; + projection: any; + events?: any[]; + restoredLive?: any; + replay?: any; + canonicalReexport?: any; + transitions?: any[]; +}; + +const fixturePath = path.join(__dirname, '../fixtures/durable-graph-engine-continuation-child.ts'); + +function runChild(mode: 'produce' | 'continue', artifactDirectory: string): void { + execFileSync( + process.execPath, + ['-r', 'ts-node/register/transpile-only', fixturePath, mode, artifactDirectory], + { + cwd: path.resolve(__dirname, '../..'), + env: { + ...process.env, + TS_NODE_TRANSPILE_ONLY: '1', + VISOR_WORKSPACE_ENABLED: 'true', + VISOR_CONTINUATION_WORKSPACE_PATH: path.join(artifactDirectory, 'workspaces'), + }, + encoding: 'utf8', + timeout: 120_000, + stdio: 'pipe', + } + ); +} + +function readArtifact(directory: string, name: string): Artifact { + return JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')) as Artifact; +} + +function rehash(checkpoint: any): any { + const body = { + kind: checkpoint.kind, + version: checkpoint.version, + sessionId: checkpoint.sessionId, + graphSemanticDigest: checkpoint.graphSemanticDigest, + frontier: checkpoint.frontier, + events: checkpoint.events, + }; + return { ...checkpoint, integrity: { algorithm: 'sha256', digest: sha256Canonical(body) } }; +} + +function continuationInput(checkpoint: unknown, owner = OWNER): GraphCheckpointContinuationInput { + return { checkpoint, expansionOwnerCheck: owner, config: config(), prInfo }; +} + +function instanceSlice(projection: any, itemKey: string): unknown { + const instance = Object.values(projection.instancesById).find( + (candidate: any) => candidate.itemKey === itemKey + ) as any; + if (!instance) return undefined; + const nodeIds = Object.values(instance.nodeInstanceIdsByTemplateNode) as string[]; + return { + instance, + nodes: nodeIds + .map(nodeId => projection.nodesById[nodeId]) + .sort((left: any, right: any) => left.nodeInstanceId.localeCompare(right.nodeInstanceId)), + generations: Object.values(projection.generationsById) + .filter((generation: any) => generation.subgraphInstanceId === instance.subgraphInstanceId) + .sort((left: any, right: any) => left.nodeGenerationId.localeCompare(right.nodeGenerationId)), + claims: Object.values(projection.claimsById) + .filter((claim: any) => claim.subgraphInstanceId === instance.subgraphInstanceId) + .sort((left: any, right: any) => left.claimId.localeCompare(right.claimId)), + }; +} + +describe('durable Graph checkpoint continuation', () => { + let artifactDirectory: string; + let producer: Artifact; + + beforeAll(() => { + artifactDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'visor-graph-continuation-')); + // This is child A. The continuation assertion below launches the only + // other fixture child, in a fresh process with a fresh module cache. + runChild('produce', artifactDirectory); + producer = readArtifact(artifactDirectory, 'producer.json'); + }); + + afterAll(() => { + fs.rmSync(artifactDirectory, { recursive: true, force: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + MemoryStore.resetInstance(); + }); + + it('rejects an altered payload before services, tools, or context installation', async () => { + const altered = JSON.parse(JSON.stringify(producer.checkpoint)); + altered.events[0].sessionId = `${altered.events[0].sessionId}-altered`; + const initialize = jest.spyOn(MemoryStore.prototype, 'initialize'); + const engine = new StateMachineExecutionEngine(); + + await expect(engine.continueGraphCheckpoint(continuationInput(altered))).rejects.toMatchObject({ + code: 'CHECKPOINT_INTEGRITY_MISMATCH', + }); + expect(initialize).not.toHaveBeenCalled(); + expect((engine as any)._lastContext).toBeUndefined(); + expect((engine as any)._lastRunner).toBeUndefined(); + expect(() => engine.getInstanceProjection()).toThrow( + expect.objectContaining({ code: 'RUN_NOT_ACTIVE' }) + ); + expect(() => engine.requestCatalogReconciliation(OWNER)).toThrow( + expect.objectContaining({ code: 'RUN_NOT_ACTIVE' }) + ); + }); + + it('rejects a validly rehashed checkpoint for a different compiled graph', async () => { + const mismatched = rehash({ + ...producer.checkpoint, + graphSemanticDigest: 'different-graph-digest', + }); + const initialize = jest.spyOn(MemoryStore.prototype, 'initialize'); + const engine = new StateMachineExecutionEngine(); + + await expect( + engine.continueGraphCheckpoint(continuationInput(mismatched)) + ).rejects.toMatchObject({ + code: 'CHECKPOINT_GRAPH_MISMATCH', + }); + expect(initialize).not.toHaveBeenCalled(); + expect((engine as any)._lastContext).toBeUndefined(); + expect((engine as any)._lastRunner).toBeUndefined(); + }); + + it('rejects a hashed nonquiescent prefix before creating fresh services', async () => { + const plan = compileClaimPlan(config()); + const journal = ExecutionJournal.restoreGraphCheckpoint(plan, producer.checkpoint); + journal.requestCatalogReconciliation({ + sessionId: producer.checkpoint.sessionId, + ownerCheck: OWNER, + }); + const pending = journal.exportGraphCheckpoint(producer.checkpoint.sessionId); + const initialize = jest.spyOn(MemoryStore.prototype, 'initialize'); + const engine = new StateMachineExecutionEngine(); + + await expect(engine.continueGraphCheckpoint(continuationInput(pending))).rejects.toMatchObject({ + code: 'CHECKPOINT_NOT_QUIESCENT', + }); + expect(initialize).not.toHaveBeenCalled(); + expect((engine as any)._lastContext).toBeUndefined(); + expect((engine as any)._lastRunner).toBeUndefined(); + }); + + it('rejects an unknown owner without appending a request suffix', async () => { + const initialize = jest.spyOn(MemoryStore.prototype, 'initialize'); + const engine = new StateMachineExecutionEngine(); + + await expect( + engine.continueGraphCheckpoint(continuationInput(producer.checkpoint, 'unknown-owner')) + ).rejects.toMatchObject({ code: 'UNKNOWN_EXPANSION_OWNER' }); + expect(initialize).not.toHaveBeenCalled(); + expect((engine as any)._lastContext).toBeUndefined(); + expect((engine as any)._lastRunner).toBeUndefined(); + }); + + it('preserves prior private run references when continuation setup fails', async () => { + const priorContext = { marker: 'prior-context' }; + const priorRunner = { marker: 'prior-runner' }; + const engine = new StateMachineExecutionEngine(); + (engine as any)._lastContext = priorContext; + (engine as any)._lastRunner = priorRunner; + const initialize = jest + .spyOn(MemoryStore.prototype, 'initialize') + .mockRejectedValueOnce(new Error('fixture memory setup failure')); + + await expect( + engine.continueGraphCheckpoint(continuationInput(producer.checkpoint)) + ).rejects.toThrow('fixture memory setup failure'); + expect(initialize).toHaveBeenCalledTimes(1); + expect((engine as any)._lastContext).toBe(priorContext); + expect((engine as any)._lastRunner).toBe(priorRunner); + }); + + it('continues one changed keyed closure in a separate process and round-trips the result', () => { + // This is child B, and the test intentionally stops after the returned + // checkpoint has been restored and canonically re-exported by the child. + runChild('continue', artifactDirectory); + const continuation = readArtifact(artifactDirectory, 'continuation.json'); + const sourceEvents = producer.checkpoint.events; + const returnedEvents = continuation.checkpoint.events; + const suffix = returnedEvents.slice(sourceEvents.length); + + expect(continuation.pid).not.toBe(producer.pid); + expect(returnedEvents.slice(0, sourceEvents.length)).toEqual(sourceEvents); + expect(new Set(returnedEvents.map((event: any) => event.sessionId))).toEqual( + new Set([producer.checkpoint.sessionId]) + ); + expect(continuation.checkpoint.sessionId).toBe(producer.checkpoint.sessionId); + + for (const field of [ + 'requestsById', + 'instancesById', + 'nodesById', + 'generationsById', + 'claimsById', + ]) { + for (const id of Object.keys(producer.projection[field])) { + expect(continuation.projection[field][id]).toBeDefined(); + } + } + + const sourceA = instanceSlice(producer.projection, 'A') as any; + const sourceB = instanceSlice(producer.projection, 'B') as any; + expect(sourceA.generations).toHaveLength(2); + expect(sourceB.generations).toHaveLength(2); + expect(sourceA.generations.every((generation: any) => generation.status === 'completed')).toBe( + true + ); + expect(sourceB.generations.every((generation: any) => generation.status === 'completed')).toBe( + true + ); + expect(instanceSlice(continuation.projection, 'B')).toEqual(sourceB); + const continuedA = instanceSlice(continuation.projection, 'A') as any; + expect(continuedA.nodes).toEqual(sourceA.nodes); + const sourceAGenerationIds = new Set( + sourceA.generations.map((generation: any) => generation.nodeGenerationId) + ); + const continuedAGenerationIds = new Set( + continuedA.generations.map((generation: any) => generation.nodeGenerationId) + ); + for (const id of sourceAGenerationIds) expect(continuedAGenerationIds.has(id)).toBe(true); + const oldAGenerations = continuedA.generations.filter((generation: any) => + sourceAGenerationIds.has(generation.nodeGenerationId) + ); + const replacementAGenerations = continuedA.generations.filter( + (generation: any) => !sourceAGenerationIds.has(generation.nodeGenerationId) + ); + expect(oldAGenerations).toHaveLength(sourceA.generations.length); + expect(oldAGenerations.every((generation: any) => generation.status === 'inactive')).toBe(true); + expect(replacementAGenerations).toHaveLength(sourceA.generations.length); + expect( + replacementAGenerations.every((generation: any) => generation.status === 'completed') + ).toBe(true); + + expect( + continuation.calls.map(call => `${call.kind}:${call.checkId}:${call.key || ''}`) + ).toEqual(['owner:discover-items:', 'generated:inspect:A', 'generated:summarize:A']); + expect(continuation.calls.some(call => call.key === 'B')).toBe(false); + expect(continuation.calls[0].sessionId).toBe(producer.checkpoint.sessionId); + const workspaceBasePath = path.join(artifactDirectory, 'workspaces'); + expect(producer.calls[0].workingDirectory).toContain(`${workspaceBasePath}${path.sep}`); + expect(continuation.calls[0].workingDirectory).toContain(`${workspaceBasePath}${path.sep}`); + expect(producer.calls[0].workingDirectoryExists).toBe(true); + expect(continuation.calls[0].workingDirectoryExists).toBe(true); + expect(producer.calls[0].workingDirectory).not.toBe(process.cwd()); + expect(continuation.calls[0].workingDirectory).not.toBe(process.cwd()); + expect(fs.existsSync(producer.calls[0].workingDirectory)).toBe(false); + expect(fs.existsSync(continuation.calls[0].workingDirectory)).toBe(false); + expect( + continuation.calls.slice(1).every(call => call.sessionId === producer.checkpoint.sessionId) + ).toBe(true); + + const sourceCompletedGenerationIds = new Set( + Object.values(producer.projection.generationsById) + .filter((generation: any) => generation.status === 'completed') + .map((generation: any) => generation.nodeGenerationId) + ); + expect(sourceCompletedGenerationIds.size).toBeGreaterThan(0); + + const rootStarts = suffix.filter( + (event: any) => event.type === 'AttemptStarted' && !('nodeGenerationId' in event) + ); + expect(rootStarts).toHaveLength(1); + expect(rootStarts[0].requestId).toBe(continuation.requestId); + const priorRequests = producer.checkpoint.events.filter( + (event: any) => + event.type === 'CatalogReconciliationRequested' && event.expansionOwnerCheck === OWNER + ); + const suffixRequests = suffix.filter( + (event: any) => + event.type === 'CatalogReconciliationRequested' && event.expansionOwnerCheck === OWNER + ); + expect(suffixRequests).toHaveLength(1); + expect(suffixRequests[0].requestOrdinal).toBe( + Math.max(0, ...priorRequests.map((event: any) => event.requestOrdinal)) + 1 + ); + const priorRootCatalogAttempts = producer.checkpoint.events.filter( + (event: any) => + event.type === 'AttemptStarted' && + !('nodeGenerationId' in event) && + event.checkId === OWNER && + event.scope.length === 0 + ); + expect(rootStarts[0].attemptId).toBe( + sha256Canonical({ + sessionId: producer.checkpoint.sessionId, + checkId: OWNER, + scope: [], + ordinal: priorRootCatalogAttempts.length + 1, + }) + ); + const suffixGeneratedStarts = suffix.filter( + (event: any) => event.type === 'AttemptStarted' && 'nodeGenerationId' in event + ); + expect(suffixGeneratedStarts).toHaveLength(2); + const replacementGenerationIds = new Set( + replacementAGenerations.map((generation: any) => generation.nodeGenerationId) + ); + const suffixGeneratedGenerationIds = new Set( + suffixGeneratedStarts.map((event: any) => event.nodeGenerationId) + ); + expect( + suffixGeneratedStarts.every( + (event: any) => !sourceCompletedGenerationIds.has(event.nodeGenerationId) + ) + ).toBe(true); + for (const event of suffixGeneratedStarts) { + expect(event.attemptId).toBe( + sha256Canonical({ nodeGenerationId: event.nodeGenerationId, ordinal: 1 }) + ); + } + + const generatedCalls = continuation.calls.filter(call => call.kind === 'generated'); + const terminatedManagedBindingGenerationIds = new Set( + generatedCalls + .map(call => continuation.projection.managedRunsByAttemptId[call.binding.attemptId]) + .filter(managed => managed?.status === 'terminated') + .map(managed => managed.binding.nodeGenerationId) + ); + expect(replacementGenerationIds.size).toBe(2); + expect(suffixGeneratedGenerationIds.size).toBe(2); + expect(terminatedManagedBindingGenerationIds.size).toBe(2); + expect(suffixGeneratedGenerationIds).toEqual(replacementGenerationIds); + expect(terminatedManagedBindingGenerationIds).toEqual(replacementGenerationIds); + + for (const call of generatedCalls) { + expect(continuation.projection.generationsById[call.binding.nodeGenerationId]).toMatchObject({ + status: 'completed', + attemptId: call.binding.attemptId, + fence: call.binding.fence, + }); + expect(continuation.projection.managedRunsByAttemptId[call.binding.attemptId]).toMatchObject({ + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'completed', + }); + } + + expect(suffix.map((event: any) => event.eventId)).toEqual( + suffix.map( + (_: unknown, index: number) => producer.checkpoint.frontier.lastEventId + index + 1 + ) + ); + const suffixAttempts = suffix.filter((event: any) => event.type === 'AttemptStarted'); + const priorFence = Math.max( + 0, + ...producer.checkpoint.events + .filter((event: any) => event.type === 'AttemptStarted') + .map((event: any) => event.fence) + ); + expect(suffixAttempts.map((event: any) => event.fence)).toEqual( + suffixAttempts.map((_: unknown, index: number) => priorFence + index + 1) + ); + + expect(continuation.transitions?.[0]).toEqual({ + type: 'StateTransition', + from: 'LevelDispatch', + to: 'LevelDispatch', + }); + expect(continuation.restoredLive).toEqual(continuation.projection); + expect(continuation.replay).toEqual(continuation.restoredLive); + expect(continuation.canonicalReexport).toEqual(continuation.checkpoint); + expect(continuation.checkpoint.frontier.eventCount).toBe(returnedEvents.length); + expect(continuation.result).toBeDefined(); + }); +}); diff --git a/tests/engine/dynamic-component-instances.exp-0122.engine.test.ts b/tests/engine/dynamic-component-instances.exp-0122.engine.test.ts new file mode 100644 index 000000000..22b8978ac --- /dev/null +++ b/tests/engine/dynamic-component-instances.exp-0122.engine.test.ts @@ -0,0 +1,707 @@ +import { StateMachineExecutionEngine } from '../../src/state-machine-execution-engine'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { + CheckProvider, + type CheckProviderConfig, + type ExecutionContext, +} from '../../src/providers/check-provider.interface'; +import { + queryReadyGenerations, + replayInstanceEvents, +} from '../../src/state-machine/graph/instance-kernel'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { ReviewSummary } from '../../src/reviewer'; +import type { VisorConfig } from '../../src/types/config'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const prInfo = { + number: 1, + title: 'Graph v2 dynamic component instances', + author: 'test', + base: 'main', + head: 'candidate', + files: [], + totalAdditions: 0, + totalDeletions: 0, + eventType: 'manual', +} as PRInfo; + +function dynamicConfig(): VisorConfig { + return { + version: '1.0', + max_parallelism: 2, + workspace: { enabled: false }, + claim_types: { + 'component.catalog@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['components'], + properties: { + components: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'path'], + properties: { + id: { type: 'string', minLength: 1 }, + path: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }, + }, + 'component.item@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'path'], + properties: { + id: { type: 'string', minLength: 1 }, + path: { type: 'string', minLength: 1 }, + }, + }, + }, + 'component.onboarded@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'findings'], + properties: { + id: { type: 'string', minLength: 1 }, + findings: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + subgraphs: { + 'onboard-component': { + input: { name: 'component', claim: 'component.item@1' }, + checks: { + inspect: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: 'component.onboarded@1', from: 'output' }], + }, + summarize: { + type: 'noop', + consumes: [{ claim: 'component.onboarded@1', as: 'inspected' }], + }, + }, + }, + }, + checks: { + 'discover-components': { + type: 'noop', + emits: [{ claim: 'component.catalog@1', from: 'output' }], + expand: { + claim: 'component.catalog@1', + template: 'onboard-component', + items_pointer: '/components', + key_pointer: '/id', + item_claim: 'component.item@1', + }, + }, + }, + }; +} + +function instanceFor(projection: any, key: string): any { + return Object.values(projection.instancesById).find((instance: any) => instance.itemKey === key); +} + +function stableInstanceSlice(projection: any, key: string): unknown { + const instance = instanceFor(projection, key); + const nodeIds = Object.values(instance.nodeInstanceIdsByTemplateNode) as string[]; + return { + instance, + nodes: nodeIds.map(id => projection.nodesById[id]).sort((a, b) => + a.nodeInstanceId.localeCompare(b.nodeInstanceId) + ), + generations: Object.values(projection.generationsById) + .filter((generation: any) => generation.subgraphInstanceId === instance.subgraphInstanceId) + .sort((a: any, b: any) => a.nodeGenerationId.localeCompare(b.nodeGenerationId)), + claims: Object.values(projection.claimsById) + .filter((claim: any) => claim.subgraphInstanceId === instance.subgraphInstanceId) + .sort((a: any, b: any) => a.claimId.localeCompare(b.claimId)), + }; +} + +function isInstanceEvent(event: any): boolean { + return ( + [ + 'CatalogReconciliationRequested', + 'SubgraphExpanded', + 'ControllerItemClaimPublished', + 'NodeGenerationInactivated', + 'NodeGenerationActivated', + 'SubgraphTombstoned', + ].includes(event.type) || + 'nodeGenerationId' in event || + 'requestId' in event + ); +} + +describe('EXP-0122 dynamic component instances', () => { + const registry = CheckProviderRegistry.getInstance(); + const originalNoop = registry.getProviderOrThrow('noop'); + + let engine: StateMachineExecutionEngine; + let catalogs: Array>; + let catalogIndex: number; + let catalogStarts: Array>; + let catalogGates: Array>; + let bInspectGate: ReturnType; + let firstASummaryStarted: ReturnType; + let activeProviders: number; + let peakProviders: number; + let calls: Array<{ version: number; key: string; checkId: string }>; + let contextEvidence: Array<{ + version: number; + key: string; + checkId: string; + aliases: string[]; + dependencyKeys: string[]; + dependencyOutputs: unknown[]; + claimPayload: unknown; + scope: unknown; + historySize: number; + hasParentContext: boolean; + hasParentState: boolean; + hasJournal: boolean; + claimsFrozen: boolean; + claimFrozen: boolean; + scopeFrozen: boolean; + payloadFrozen: boolean; + provenance: unknown; + attemptId: unknown; + fence: unknown; + catalogClaimId: unknown; + incarnation: unknown; + scheduledBeforeProvider: boolean; + pendingRequestIds: string[]; + }>; + let boundaryProjections: any[]; + + class ControlledNoopProvider extends CheckProvider { + getName() { + return 'noop'; + } + getDescription() { + return 'EXP-0122 deterministic fake'; + } + async validateConfig() { + return true; + } + async isAvailable() { + return true; + } + getRequirements() { + return []; + } + getSupportedConfigKeys() { + return ['type']; + } + async execute( + _pr: PRInfo, + config: CheckProviderConfig, + dependencies?: Map, + context?: ExecutionContext + ): Promise { + const checkId = String(config.checkName); + activeProviders++; + peakProviders = Math.max(peakProviders, activeProviders); + try { + if (checkId === 'discover-components') { + const version = catalogIndex++; + if (version > 0) { + boundaryProjections[version - 1] = (engine as any)._lastContext.journal + .getInstanceProjection(); + } + catalogStarts[version].resolve(); + await catalogGates[version].promise; + return { issues: [], output: { components: catalogs[version] } }; + } + + const version = catalogIndex - 1; + const aliases = Object.keys(context?.claims || {}).sort(); + const claim = context?.claims?.component || context?.claims?.inspected; + const payload = claim?.payload as { id: string; path?: string; findings?: string[] }; + const key = payload.id; + const journal = (engine as any)._lastContext.journal; + const projection = journal.getInstanceProjection(); + const pendingRequestIds = projection.requestOrder.filter( + (requestId: string) => projection.requestsById[requestId].status === 'pending' + ); + const history = config.__outputHistory as Map | undefined; + const rawContext = (context || {}) as Record; + + calls.push({ version, key, checkId }); + contextEvidence.push({ + version, + key, + checkId, + aliases, + dependencyKeys: [...(dependencies?.keys() || [])].sort(), + dependencyOutputs: [...(dependencies?.values() || [])].map(result => result.output), + claimPayload: claim?.payload, + scope: context?.scope, + historySize: history?.size ?? -1, + hasParentContext: '_parentContext' in rawContext, + hasParentState: '_parentState' in rawContext, + hasJournal: 'journal' in rawContext, + claimsFrozen: Object.isFrozen(context?.claims), + claimFrozen: Object.isFrozen(claim), + scopeFrozen: Object.isFrozen(context?.scope) && Object.isFrozen(context?.scope?.[0]), + payloadFrozen: Object.isFrozen(claim?.payload), + provenance: claim?.provenance, + attemptId: claim?.attemptId, + fence: claim?.fence, + catalogClaimId: + claim?.provenance === 'controller' ? claim.catalogClaimId : undefined, + incarnation: claim?.provenance === 'controller' ? claim.incarnation : undefined, + scheduledBeforeProvider: journal.readRuntimeEvents().some( + (event: any) => + event.type === 'CheckScheduled' && + event.nodeGenerationId === context?.nodeGenerationId + ), + pendingRequestIds, + }); + + if (key === 'B' && checkId === 'inspect' && version === 0) { + await bInspectGate.promise; + } + if (key === 'A' && checkId === 'summarize' && version === 0) { + firstASummaryStarted.resolve(); + } + if (checkId === 'inspect') { + return { issues: [], output: { id: key, findings: [String(payload.path)] } }; + } + return { issues: [], output: { id: key, summarized: true } }; + } finally { + activeProviders--; + } + } + } + + beforeEach(() => { + engine = new StateMachineExecutionEngine(); + catalogs = [ + [ + { id: 'A', path: 'a1' }, + { id: 'B', path: 'b1' }, + ], + [ + { id: 'B', path: 'b1' }, + { id: 'A', path: 'a1' }, + ], + [ + { id: 'B', path: 'b1' }, + { id: 'A', path: 'a1' }, + { id: 'C', path: 'c1' }, + ], + [ + { id: 'B', path: 'b1' }, + { id: 'A', path: 'a1' }, + ], + [ + { id: 'B', path: 'b1' }, + { id: 'A', path: 'a2' }, + ], + ]; + catalogIndex = 0; + catalogStarts = catalogs.map(() => deferred()); + catalogGates = catalogs.map(() => deferred()); + bInspectGate = deferred(); + firstASummaryStarted = deferred(); + activeProviders = 0; + peakProviders = 0; + calls = []; + contextEvidence = []; + boundaryProjections = []; + registry.unregister('noop'); + registry.register(new ControlledNoopProvider()); + }); + + afterEach(() => { + registry.unregister('noop'); + registry.register(originalNoop); + }); + + it('runs one live five-version FIFO lifecycle with stable reuse and exact keyed authority', async () => { + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + dynamicConfig(), + 'table', + false, + 2 + ); + const requestIds: string[] = []; + const queuedRequestProjections: any[] = []; + + for (let version = 0; version < catalogs.length; version++) { + await catalogStarts[version].promise; + if (version < catalogs.length - 1) { + const request = engine.requestCatalogReconciliation('discover-components'); + requestIds.push(request.requestId); + const projection = (engine as any)._lastContext.journal.getInstanceProjection(); + queuedRequestProjections.push(projection); + } + catalogGates[version].resolve(); + if (version === 0) { + await firstASummaryStarted.promise; + bInspectGate.resolve(); + } + } + await run; + + const journal = (engine as any)._lastContext.journal; + const events = journal.readRuntimeEvents() as readonly any[]; + const finalProjection = journal.getInstanceProjection(); + boundaryProjections[4] = finalProjection; + + expect(catalogIndex).toBe(5); + expect(peakProviders).toBe(2); + expect(activeProviders).toBe(0); + expect([...new Set(events.map(event => event.sessionId).filter(Boolean))]).toHaveLength(1); + expect(events.some(event => event.type === 'ForwardRunRequested')).toBe(false); + + for (const [index, requestId] of requestIds.entries()) { + expect(queuedRequestProjections[index].requestsById[requestId].status).toBe('pending'); + } + expect( + contextEvidence + .filter(evidence => evidence.pendingRequestIds.length > 0) + .map(evidence => evidence.version) + ).toEqual(expect.arrayContaining([0, 2])); + + expect(calls.filter(call => call.key === 'A' && call.checkId === 'inspect')).toHaveLength(2); + expect(calls.filter(call => call.key === 'A' && call.checkId === 'summarize')).toHaveLength(2); + expect(calls.filter(call => call.key === 'B' && call.checkId === 'inspect')).toHaveLength(1); + expect(calls.filter(call => call.key === 'B' && call.checkId === 'summarize')).toHaveLength(1); + expect(calls.filter(call => call.key === 'C' && call.checkId === 'inspect')).toHaveLength(1); + expect(calls.filter(call => call.key === 'C' && call.checkId === 'summarize')).toHaveLength(1); + expect(calls.filter(call => call.version === 1)).toEqual([]); + expect(calls.filter(call => call.version === 2).map(call => call.key)).toEqual(['C', 'C']); + expect(calls.filter(call => call.version === 3)).toEqual([]); + expect(calls.filter(call => call.version === 4).map(call => call.key)).toEqual(['A', 'A']); + + for (const evidence of contextEvidence) { + expect(evidence.aliases).toEqual([ + evidence.checkId === 'inspect' ? 'component' : 'inspected', + ]); + expect(evidence.dependencyKeys).toEqual([ + evidence.checkId === 'inspect' ? 'discover-components' : 'inspect', + ]); + expect(evidence.dependencyOutputs).toEqual([evidence.claimPayload]); + expect(evidence.dependencyOutputs).toHaveLength(1); + expect(evidence.dependencyOutputs[0]).not.toHaveProperty('components'); + expect(evidence.dependencyOutputs[0]).toMatchObject({ id: evidence.key }); + expect(evidence.scope).toEqual([ + { + kind: 'keyed', + expansionOwnerCheck: 'discover-components', + key: evidence.key, + subgraphInstanceId: instanceFor(finalProjection, evidence.key).subgraphInstanceId, + }, + ]); + expect(evidence.historySize).toBe(0); + expect(evidence.hasParentContext).toBe(false); + expect(evidence.hasParentState).toBe(false); + expect(evidence.hasJournal).toBe(false); + expect(evidence.claimsFrozen).toBe(true); + expect(evidence.claimFrozen).toBe(true); + expect(evidence.scopeFrozen).toBe(true); + expect(evidence.payloadFrozen).toBe(true); + expect(evidence.scheduledBeforeProvider).toBe(true); + if (evidence.checkId === 'inspect') { + expect(evidence.provenance).toBe('controller'); + expect(evidence.attemptId).toBeUndefined(); + expect(evidence.fence).toBeUndefined(); + expect(evidence.catalogClaimId).toMatch(/^[0-9a-f]{64}$/); + expect(evidence.incarnation).toBe(evidence.version === 4 ? 2 : 1); + } else { + expect(evidence.provenance).toBe('attempt'); + expect(evidence.attemptId).toMatch(/^[0-9a-f]{64}$/); + expect(evidence.fence).toEqual(expect.any(Number)); + expect(evidence.catalogClaimId).toBeUndefined(); + expect(evidence.incarnation).toBeUndefined(); + } + } + + expect(stableInstanceSlice(boundaryProjections[0], 'A')).toEqual( + stableInstanceSlice(boundaryProjections[1], 'A') + ); + expect(stableInstanceSlice(boundaryProjections[0], 'A')).toEqual( + stableInstanceSlice(boundaryProjections[2], 'A') + ); + expect(stableInstanceSlice(boundaryProjections[0], 'A')).toEqual( + stableInstanceSlice(boundaryProjections[3], 'A') + ); + for (const projection of boundaryProjections.slice(1)) { + expect(stableInstanceSlice(projection, 'B')).toEqual( + stableInstanceSlice(boundaryProjections[0], 'B') + ); + } + + const initialA = instanceFor(boundaryProjections[0], 'A'); + const finalA = instanceFor(finalProjection, 'A'); + expect(finalA.subgraphInstanceId).toBe(initialA.subgraphInstanceId); + expect(finalA.nodeInstanceIdsByTemplateNode).toEqual(initialA.nodeInstanceIdsByTemplateNode); + expect(finalA.incarnation).toBe(2); + const initialAGenerationIds = Object.values(boundaryProjections[0].generationsById) + .filter((generation: any) => generation.subgraphInstanceId === initialA.subgraphInstanceId) + .map((generation: any) => generation.nodeGenerationId); + for (const generationId of initialAGenerationIds) { + expect(finalProjection.generationsById[generationId].status).toBe('inactive'); + for (const claimId of finalProjection.generationsById[generationId].completedOutputClaimIds) { + expect(finalProjection.claimsById[claimId].active).toBe(false); + } + } + const activeAFinal = Object.values(finalProjection.generationsById).filter( + (generation: any) => + generation.subgraphInstanceId === finalA.subgraphInstanceId && + generation.status !== 'inactive' + ) as any[]; + expect(activeAFinal).toHaveLength(2); + expect(activeAFinal.every(generation => generation.incarnation === 2)).toBe(true); + expect(activeAFinal.every(generation => generation.status === 'completed')).toBe(true); + + const addedC = instanceFor(boundaryProjections[2], 'C'); + const removedC = instanceFor(boundaryProjections[3], 'C'); + expect(addedC.status).toBe('active'); + expect(removedC.subgraphInstanceId).toBe(addedC.subgraphInstanceId); + expect(removedC.status).toBe('tombstoned'); + for (const generation of Object.values(finalProjection.generationsById).filter( + (value: any) => value.subgraphInstanceId === removedC.subgraphInstanceId + ) as any[]) { + expect(generation.status).toBe('inactive'); + for (const claimId of generation.completedOutputClaimIds) { + expect(finalProjection.claimsById[claimId].active).toBe(false); + } + } + + const catalogClaims = events.filter( + event => event.type === 'ClaimPublished' && event.claim === 'component.catalog@1' + ); + expect(catalogClaims).toHaveLength(5); + const catalogBatches = catalogClaims.map(claim => { + const completed = events.find( + event => event.type === 'AttemptCompleted' && event.attemptId === claim.attemptId + ); + return events.filter( + event => event.eventId > claim.eventId && event.eventId < completed.eventId + ); + }); + const diffTypes = new Set([ + 'SubgraphExpanded', + 'ControllerItemClaimPublished', + 'NodeGenerationInactivated', + 'NodeGenerationActivated', + 'SubgraphTombstoned', + ]); + const catalogDiffs = catalogBatches.map(batch => + batch.filter(event => diffTypes.has(event.type)) + ); + const eventKey = (event: any): string => event.itemKey || event.scope?.[0]?.key; + expect(catalogDiffs[0].map(event => [event.type, eventKey(event)])).toEqual([ + ['SubgraphExpanded', 'A'], + ['ControllerItemClaimPublished', 'A'], + ['NodeGenerationActivated', 'A'], + ['SubgraphExpanded', 'B'], + ['ControllerItemClaimPublished', 'B'], + ['NodeGenerationActivated', 'B'], + ]); + const initialB = instanceFor(boundaryProjections[0], 'B'); + expect(catalogDiffs[0][0].subgraphInstanceId).toBe(initialA.subgraphInstanceId); + expect(catalogDiffs[0][1].subgraphInstanceId).toBe(initialA.subgraphInstanceId); + expect(catalogDiffs[0][2].nodeInstanceId).toBe( + initialA.nodeInstanceIdsByTemplateNode.inspect + ); + expect(catalogDiffs[0][3].subgraphInstanceId).toBe(initialB.subgraphInstanceId); + expect(catalogDiffs[0][4].subgraphInstanceId).toBe(initialB.subgraphInstanceId); + expect(catalogDiffs[0][5].nodeInstanceId).toBe( + initialB.nodeInstanceIdsByTemplateNode.inspect + ); + expect(catalogDiffs[0].map(event => event.type)).toEqual([ + 'SubgraphExpanded', + 'ControllerItemClaimPublished', + 'NodeGenerationActivated', + 'SubgraphExpanded', + 'ControllerItemClaimPublished', + 'NodeGenerationActivated', + ]); + expect(catalogDiffs[1]).toEqual([]); + expect(catalogs[1].map(item => item.id)).toEqual(['B', 'A']); + expect(catalogDiffs[2].map(event => [event.type, eventKey(event)])).toEqual([ + ['SubgraphExpanded', 'C'], + ['ControllerItemClaimPublished', 'C'], + ['NodeGenerationActivated', 'C'], + ]); + expect(catalogDiffs[2][0].subgraphInstanceId).toBe(addedC.subgraphInstanceId); + expect(catalogDiffs[2][1].subgraphInstanceId).toBe(addedC.subgraphInstanceId); + expect(catalogDiffs[2][2].nodeInstanceId).toBe( + addedC.nodeInstanceIdsByTemplateNode.inspect + ); + expect(catalogDiffs[3].map(event => [event.type, eventKey(event)])).toEqual([ + ['SubgraphTombstoned', 'C'], + ]); + expect(catalogDiffs[3][0].subgraphInstanceId).toBe(addedC.subgraphInstanceId); + expect(catalogDiffs[4].map(event => [event.type, eventKey(event)])).toEqual([ + ['NodeGenerationInactivated', 'A'], + ['NodeGenerationInactivated', 'A'], + ['ControllerItemClaimPublished', 'A'], + ['NodeGenerationActivated', 'A'], + ]); + expect(catalogDiffs[4].slice(0, 2).map(event => event.nodeInstanceId)).toEqual([ + initialA.nodeInstanceIdsByTemplateNode.summarize, + initialA.nodeInstanceIdsByTemplateNode.inspect, + ]); + expect(catalogDiffs[4][2].subgraphInstanceId).toBe(initialA.subgraphInstanceId); + expect(catalogDiffs[4][3].nodeInstanceId).toBe( + initialA.nodeInstanceIdsByTemplateNode.inspect + ); + + for (const expanded of events.filter(event => event.type === 'SubgraphExpanded')) { + expect(expanded).not.toHaveProperty('claimId'); + expect(expanded).not.toHaveProperty('incarnation'); + expect(expanded).not.toHaveProperty('nodeGenerationId'); + } + for (const itemClaim of events.filter( + event => event.type === 'ControllerItemClaimPublished' + )) { + expect(itemClaim.parentClaimIds).toEqual([itemClaim.catalogClaimId]); + expect(itemClaim.scope).toEqual([ + { + kind: 'keyed', + expansionOwnerCheck: itemClaim.expansionOwnerCheck, + key: itemClaim.itemKey, + subgraphInstanceId: itemClaim.subgraphInstanceId, + }, + ]); + } + + const generatedLifecycle = events.filter( + event => 'nodeGenerationId' in event && 'attemptId' in event + ); + for (const event of generatedLifecycle) { + const generation = finalProjection.generationsById[event.nodeGenerationId]; + expect(event.nodeInstanceId).toBe(generation.nodeInstanceId); + expect(event.scope).toEqual(generation.scope); + if (event.type === 'CheckScheduled') { + expect(event.claimIds).toEqual(generation.activeInputClaimIds); + } + if (event.type === 'ClaimPublished') { + expect(event.parentClaimIds).toEqual(generation.activeInputClaimIds); + const terminal = events.find( + candidate => + candidate.type === 'AttemptCompleted' && candidate.attemptId === event.attemptId + ); + const downstream = events.filter( + candidate => + candidate.type === 'NodeGenerationActivated' && + candidate.subgraphInstanceId === generation.subgraphInstanceId && + candidate.incarnation === generation.incarnation && + candidate.eventId > event.eventId && + candidate.eventId < terminal.eventId + ); + expect(downstream).toHaveLength(1); + expect(downstream[0].templateNodeKey).toBe('summarize'); + } + } + + for (const requestId of requestIds) { + const requested = events.find( + event => event.type === 'CatalogReconciliationRequested' && event.requestId === requestId + ); + const started = events.find( + event => event.type === 'AttemptStarted' && event.requestId === requestId + ); + const scheduled = events.find( + event => event.type === 'CheckScheduled' && event.requestId === requestId + ); + const completed = events.find( + event => event.type === 'AttemptCompleted' && event.requestId === requestId + ); + expect(requested.eventId).toBeLessThan(started.eventId); + expect(started.eventId).toBeLessThan(scheduled.eventId); + expect(scheduled.eventId).toBeLessThan(completed.eventId); + + const beforeStart = replayInstanceEvents( + events.filter(event => isInstanceEvent(event) && event.eventId < started.eventId) + ); + expect(beforeStart.requestsById[requestId].status).toBe('pending'); + expect(queryReadyGenerations(beforeStart)).toEqual([]); + expect( + Object.values(beforeStart.generationsById).some( + generation => generation.status === 'running' + ) + ).toBe(false); + expect(finalProjection.requestsById[requestId].status).toBe('completed'); + } + + expect(journal.queryReadyWork()).toEqual([]); + expect(journal.replayInstanceProjection()).toEqual(finalProjection); + expect( + Object.values(finalProjection.generationsById).some( + (generation: any) => generation.status === 'ready' || generation.status === 'running' + ) + ).toBe(false); + try { + engine.requestCatalogReconciliation('discover-components'); + throw new Error('expected post-terminal request rejection'); + } catch (error) { + expect((error as Error & { code?: string }).code).toBe('RUN_NOT_ACTIVE'); + } + }); + + it('schedules and terminalizes a generated if:false attempt without provider launch', async () => { + catalogs = [[{ id: 'A', path: 'a1' }]]; + catalogStarts = [deferred()]; + catalogGates = [deferred()]; + const config = dynamicConfig(); + config.subgraphs!['onboard-component'].checks.inspect.if = 'false'; + + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 2 + ); + await catalogStarts[0].promise; + catalogGates[0].resolve(); + await run; + + expect(calls).toEqual([]); + const journal = (engine as any)._lastContext.journal; + const events = journal.readRuntimeEvents() as readonly any[]; + const projection = journal.getInstanceProjection(); + const generation = Object.values(projection.generationsById)[0] as any; + expect(generation.status).toBe('failed'); + expect(generation.scheduled).toBe(true); + expect(generation.reason).toBe('IF_CONDITION_NOT_MET'); + expect( + events + .filter(event => event.nodeGenerationId === generation.nodeGenerationId) + .map(event => event.type) + ).toEqual(['NodeGenerationActivated', 'AttemptStarted', 'CheckScheduled', 'AttemptFailed']); + expect(journal.queryReadyWork()).toEqual([]); + expect(journal.replayInstanceProjection()).toEqual(projection); + }); +}); diff --git a/tests/engine/expansion-coverage.exp-0147.engine.test.ts b/tests/engine/expansion-coverage.exp-0147.engine.test.ts new file mode 100644 index 000000000..50e3a54e1 --- /dev/null +++ b/tests/engine/expansion-coverage.exp-0147.engine.test.ts @@ -0,0 +1,318 @@ +import fs from 'fs'; +import path from 'path'; +import * as yaml from 'js-yaml'; +import { StateMachineExecutionEngine } from '../../src/state-machine-execution-engine'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { + CheckProvider, + type CheckProviderConfig, + type ManagedAgentRun, + type ManagedRunStartRequest, +} from '../../src/providers/check-provider.interface'; +import { ExecutionJournal } from '../../src/snapshot-store'; +import { compileClaimPlan } from '../../src/state-machine/graph/claim-plan'; +import { projectExpansionCoverage } from '../../src/state-machine/graph/instance-kernel'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { ReviewSummary } from '../../src/reviewer'; +import type { VisorConfig } from '../../src/types/config'; + +type Mode = 'completed_clean' | 'completed_with_findings' | 'guardrail_blocked' | + 'error' | 'cancelled' | 'running' | 'deferred'; +type Item = { id: string; mode: Mode; revision: number }; +type ResultVariant = 'changed' | 'invalid'; +const DIGESTS = { + invocation: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', + result: 'sha256:2222222222222222222222222222222222222222222222222222222222222222', + changedInvocation: 'sha256:3333333333333333333333333333333333333333333333333333333333333333', + changedResult: 'sha256:4444444444444444444444444444444444444444444444444444444444444444', +} as const; +function mappedOutcome(item: Item, terminalClass: string, variant?: ResultVariant) { + const probeResult = { + runtimeAttestation: { executionContext: { invocationDigest: variant === 'invalid' + ? 'invalid' : variant === 'changed' ? DIGESTS.changedInvocation : DIGESTS.invocation } }, + resultIdentity: { resultDigest: variant === 'changed' ? DIGESTS.changedResult : DIGESTS.result }, + data: { operation: item.id, assessment: variant === 'changed' ? 'changed' : 'stable' }, + }; + return { class: terminalClass, + invocationDigest: probeResult.runtimeAttestation.executionContext.invocationDigest, + resultDigest: probeResult.resultIdentity.resultDigest, data: probeResult.data }; +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(settle => { resolve = settle; }); + return { promise, resolve }; +} +async function until(predicate: () => boolean): Promise { + for (let index = 0; index < 100; index++) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error('fixture did not reach controlled state'); +} +const prInfo = { number: 147, title: 'coverage', author: 'test', base: 'main', head: 'poc', + files: [], totalAdditions: 0, totalDeletions: 0, eventType: 'manual' } as PRInfo; +function fixture(): VisorConfig { + return yaml.load(fs.readFileSync(path.join( + __dirname, '../fixtures/graph-v2/expansion-coverage.yaml' + ), 'utf8')) as VisorConfig; +} + +describe('EXP-0147 expansion coverage projection', () => { + const registry = CheckProviderRegistry.getInstance(); + const originalNoop = registry.getProviderOrThrow('noop'); + let catalog: Item[]; + let rootStarted: ReturnType>; + let rootRelease: ReturnType>; + let rootCalls: number; + let managedStarts: number; + let cancelCalls: number; + let gates: Map>>; + let resultVariants: Map; + + class ControlledNoop extends CheckProvider { + getName() { return 'noop'; } + getDescription() { return 'deterministic in-process EXP-0147 fake'; } + async validateConfig() { return true; } + async isAvailable() { return true; } + getRequirements() { return []; } + getSupportedConfigKeys() { return ['type']; } + async execute(_pr: PRInfo, config: CheckProviderConfig): Promise { + if (String(config.checkName) !== 'discover-operations') throw new Error('legacy throw'); + if (rootCalls++ === 0) { rootStarted.resolve(); await rootRelease.promise; } + return { issues: [], output: { operations: catalog } }; + } + startManaged(request: ManagedRunStartRequest): ManagedAgentRun { + managedStarts++; + const item = [...request.dependencyResults.values()][0].output as Item; + if (item.mode === 'error') throw new Error('real operation throw'); + const binding = request.binding; + const terminalClass = item.mode === 'deferred' || item.mode === 'running' + ? 'completed_clean' : item.mode; + const gate = gates.get(item.id); + const outcome = item.mode === 'cancelled' + ? new Promise(() => undefined) + : gate?.promise || Promise.resolve({ issues: [], + output: mappedOutcome(item, terminalClass, resultVariants.get(item.id)) }); + return { + binding, + started: Promise.resolve({ version: 1, kind: 'started', binding }), + outcome: outcome.then(summary => ({ version: 1, kind: 'succeeded', binding, summary })), + cancel: async () => { cancelCalls++; return { version: 1, kind: 'cancelled', binding, reason: 'deadline' }; }, + close: async () => ({ version: 1, kind: 'cleanup', binding, status: 'clean', + activeChildren: 0, activeResources: 0 }), + }; + } + } + + function reset(items: Item[], controlled: string[] = [], variants: Record = {}): void { + catalog = items; + rootStarted = deferred(); + rootRelease = deferred(); + rootCalls = 0; + managedStarts = 0; + cancelCalls = 0; + gates = new Map(controlled.map(key => [key, deferred()])); + resultVariants = new Map(Object.entries(variants)); + } + async function begin(items: Item[], controlled: string[] = [], timeout = 1800000, + variants: Record = {}) { + reset(items, controlled, variants); + const config = fixture(); + config.subgraphs!['assess-operation'].checks.assess.timeout = timeout; + const engine = new StateMachineExecutionEngine(); + const run = engine.executeGroupedChecks(prInfo, ['discover-operations'], undefined, config, 'table'); + await rootStarted.promise; + const request = engine.requestCatalogReconciliation('discover-operations'); + expect(engine.getExpansionCoverageRequestIds()).toEqual([request.requestId]); + expect(engine.getExpansionCoverageRequestIds('other')).toEqual([]); + rootRelease.resolve(); + return { engine, run, requestId: request.requestId }; + } + function liveAndReplay(engine: StateMachineExecutionEngine, requestId: string) { + const live = engine.getExpansionCoverageProjection(requestId); + expect(engine.replayExpansionCoverageProjection(requestId)).toEqual(live); + return live; + } + beforeEach(() => { + registry.unregister('noop'); + registry.register(new ControlledNoop()); + }); + afterEach(() => { + registry.unregister('noop'); + registry.register(originalNoop); + jest.useRealTimers(); + }); + + it('compiles only a declared sink outcome and strict pointer', () => { + const plan = compileClaimPlan(fixture()); + const valid = plan.expansionPlan.byOwner['discover-operations']; + expect(valid.coverage).toMatchObject({ outcomeClaimRef: 'operation.outcome@1', + emitterNodeKey: 'assess', classPointer: { source: '/class', tokens: ['class'] } }); + const validate = plan.validatorsByClaim['operation.outcome@1']; + const payload = mappedOutcome({ id: 'A', mode: 'completed_clean', revision: 1 }, 'completed_clean'); + expect(() => validate(payload)).not.toThrow(); + const { data: _missing, ...missing } = payload; + void _missing; + for (const rejected of [missing, { ...payload, invocationDigest: 'invalid' }, + { ...payload, extra: true }]) expect(() => validate(rejected)).toThrow(); + const invalid = fixture(); + invalid.checks!['discover-operations'].expand!.coverage!.class_pointer = 'class'; + expect(() => compileClaimPlan(invalid)).toThrow(expect.objectContaining({ code: 'INVALID_JSON_POINTER' })); + const nonSink = fixture(); + nonSink.subgraphs!['assess-operation'].checks.after = { + type: 'noop', consumes: [{ claim: 'operation.outcome@1', as: 'outcome' }], + }; + expect(() => compileClaimPlan(nonSink)).toThrow( + expect.objectContaining({ code: 'INVALID_COVERAGE_OUTCOME_EMITTER' }) + ); + }); + + it('closes two real clean operations and replays exact claim plus instance facts', async () => { + const run = await begin([ + { id: 'A', mode: 'completed_clean', revision: 1 }, + { id: 'B', mode: 'completed_clean', revision: 1 }, + ]); + await run.run; + const view = liveAndReplay(run.engine, run.requestId); + expect(view).toMatchObject({ + closure: 'closed', disposition: 'clean', terminalItems: 2, + }); + expect(view.items.every(item => item.outcomeClaimId !== null && + item.outcomePayloadFingerprint !== null)).toBe(true); + }); + + it('derives all terminal classes from provider outcomes, a throw, and a real deadline', async () => { + const modes: Mode[] = ['completed_clean', 'completed_with_findings', 'error', + 'guardrail_blocked', 'cancelled']; + const run = await begin(modes.map((mode, index) => ({ id: String(index), mode, revision: 1 })), [], 10); + await run.run; + const view = liveAndReplay(run.engine, run.requestId); + expect(view).toMatchObject({ closure: 'closed', disposition: 'unverifiable', terminalItems: 5 }); + expect(view.items.map(item => item.terminalClass)).toEqual(modes); + expect(view.items.map(item => item.outcomeClaimId !== null)).toEqual([true, true, false, true, false]); + expect(view.items.map(item => item.outcomePayloadFingerprint !== null)) + .toEqual([true, true, false, true, false]); + expect(cancelCalls).toBe(1); + }); + + it('keeps provider findings and guardrails traceable with their dispositions', async () => { + const findings = await begin([{ id: 'A', mode: 'completed_with_findings', revision: 1 }]); + await findings.run; + expect(liveAndReplay(findings.engine, findings.requestId)).toMatchObject({ + disposition: 'findings', items: [{ terminalClass: 'completed_with_findings', + outcomeClaimId: expect.any(String), outcomePayloadFingerprint: expect.any(String) }] }); + const blocked = await begin([{ id: 'A', mode: 'guardrail_blocked', revision: 1 }]); + await blocked.run; + expect(liveAndReplay(blocked.engine, blocked.requestId)).toMatchObject({ + disposition: 'unverifiable', items: [{ terminalClass: 'guardrail_blocked', + outcomeClaimId: expect.any(String), outcomePayloadFingerprint: expect.any(String) }] }); + }); + + it('stays open while a real operation is running', async () => { + const run = await begin([{ id: 'A', mode: 'running', revision: 1 }], ['A']); + await until(() => managedStarts === 1); + expect(liveAndReplay(run.engine, run.requestId)).toMatchObject({ + closure: 'open', disposition: 'unverifiable', + }); + gates.get('A')!.resolve({ issues: [], output: mappedOutcome( + { id: 'A', mode: 'running', revision: 1 }, 'completed_clean') }); + await run.run; + }); + + it('has the same digest across distinct sessions and inverted completion order', async () => { + const items: Item[] = [{ id: 'A', mode: 'deferred', revision: 1 }, + { id: 'B', mode: 'deferred', revision: 1 }]; + const execute = async (order: string[]) => { + const run = await begin(items, ['A', 'B']); + await until(() => managedStarts === 2); + for (const key of order) gates.get(key)!.resolve({ issues: [], output: mappedOutcome( + items.find(item => item.id === key)!, 'completed_clean') }); + await run.run; + return { requestId: run.requestId, view: liveAndReplay(run.engine, run.requestId) }; + }; + const forward = await execute(['A', 'B']); + const reverse = await execute(['B', 'A']); + expect(reverse.requestId).not.toBe(forward.requestId); + expect(reverse.view.semanticDigest).toBe(forward.view.semanticDigest); + expect(reverse.view.items.map(item => item.key)).toEqual(forward.view.items.map(item => item.key)); + expect(reverse.view.items.map(item => item.outcomePayloadFingerprint)) + .toEqual(forward.view.items.map(item => item.outcomePayloadFingerprint)); + reverse.view.items.forEach((item, index) => + expect(item.outcomeClaimId).not.toBe(forward.view.items[index].outcomeClaimId)); + }); + + it('changes only one payload fingerprint and the semantic digest for one changed result', async () => { + const items: Item[] = [{ id: 'A', mode: 'completed_clean', revision: 1 }, + { id: 'B', mode: 'completed_clean', revision: 1 }]; + const baseline = await begin(items); await baseline.run; + const changed = await begin(items, [], 1800000, { A: 'changed' }); await changed.run; + const before = liveAndReplay(baseline.engine, baseline.requestId); + const after = liveAndReplay(changed.engine, changed.requestId); + expect(after.semanticDigest).not.toBe(before.semanticDigest); + expect(after.items[0].outcomePayloadFingerprint).not.toBe(before.items[0].outcomePayloadFingerprint); + expect(after.items[1].outcomePayloadFingerprint).toBe(before.items[1].outcomePayloadFingerprint); + expect(after.items[1]).toMatchObject({ key: before.items[1].key, + itemFingerprint: before.items[1].itemFingerprint, terminalClass: before.items[1].terminalClass }); + }); + + it('rejects an invalid governed tuple as an unclaimed operation error', async () => { + const run = await begin([{ id: 'A', mode: 'completed_clean', revision: 1 }], + [], 1800000, { A: 'invalid' }); + await run.run; + expect(liveAndReplay(run.engine, run.requestId)).toMatchObject({ closure: 'closed', + disposition: 'unverifiable', items: [{ terminalClass: 'error', + outcomeClaimId: null, outcomePayloadFingerprint: null }] }); + }); + + it('closes an empty catalog without generated operations', async () => { + const run = await begin([]); + await run.run; + expect(managedStarts).toBe(0); + expect(liveAndReplay(run.engine, run.requestId)).toMatchObject({ + closure: 'closed', disposition: 'clean', terminalItems: 0, + }); + }); + + it('fails closed for directly constructed malformed lifecycle projections', () => { + const config = fixture(); + const plan = compileClaimPlan(config); + const journal = new ExecutionJournal(plan); + const request = journal.requestCatalogReconciliation({ sessionId: 'malformed', ownerCheck: 'discover-operations' }); + const root = journal.startCatalogRequestAttempt(request.requestId); + journal.scheduleCatalogRequestAttempt(root); + journal.completeAttempt({ ...root, payload: { operations: [{ id: 'A', mode: 'completed_clean', revision: 1 }] } }); + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + expect(journal.getExpansionCoverageProjection(request.requestId)).toMatchObject({ + closure: 'open', items: [{ terminalClass: null, outcomeClaimId: null, + outcomePayloadFingerprint: null }] }); + journal.completeGeneratedAttempt({ attempt, payload: mappedOutcome( + { id: 'A', mode: 'completed_clean', revision: 1 }, 'completed_clean') }); + const claims: any = journal.getClaimProjection(); + const instances: any = journal.getInstanceProjection(); + const expansion = plan.expansionPlan.byOwner['discover-operations']; + const project = (candidate: any) => projectExpansionCoverage(claims, candidate, expansion, request.requestId); + const generationId = Object.keys(instances.generationsById)[0]; + const claimId = instances.generationsById[generationId].completedOutputClaimIds[0]; + const itemClaimId = Object.keys(instances.claimsById).find( + key => instances.claimsById[key].kind === 'controller-item' + )!; + expect(project(instances).items[0]).toMatchObject({ outcomeClaimId: claimId, + outcomePayloadFingerprint: instances.claimsById[claimId].payloadFingerprint }); + const variants = [ + { ...instances, generationsById: { ...instances.generationsById, + [generationId]: { ...instances.generationsById[generationId], completedOutputClaimIds: [claimId, claimId] } } }, + { ...instances, instancesById: { ...instances.instancesById, + unknown: { ...Object.values(instances.instancesById)[0] as object, itemKey: 'B' } } }, + { ...instances, claimsById: { ...instances.claimsById, + [itemClaimId]: { ...instances.claimsById[itemClaimId], + payloadFingerprint: '0'.repeat(64) } } }, + { ...instances, generationsById: { ...instances.generationsById, + [generationId]: { ...instances.generationsById[generationId], status: 'running', completedOutputClaimIds: [] } } }, + ]; + for (const variant of variants) expect(project(variant)).toMatchObject({ closure: 'open', disposition: 'unverifiable' }); + for (const index of [0, 2, 3]) expect(project(variants[index]).items[0]).toMatchObject({ + outcomeClaimId: null, outcomePayloadFingerprint: null }); + }); +}); diff --git a/tests/engine/managed-run-ownership.exp-0123.engine.test.ts b/tests/engine/managed-run-ownership.exp-0123.engine.test.ts new file mode 100644 index 000000000..538e952a9 --- /dev/null +++ b/tests/engine/managed-run-ownership.exp-0123.engine.test.ts @@ -0,0 +1,2749 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; + +jest.mock('../../src/state-machine/dispatch/managed-run', () => { + const actual = jest.requireActual( + '../../src/state-machine/dispatch/managed-run' + ); + return { ...actual, snapshotManagedRun: jest.fn(actual.snapshotManagedRun) }; +}); +jest.mock('../../src/telemetry/trace-helpers', () => { + const actual = jest.requireActual( + '../../src/telemetry/trace-helpers' + ); + return { ...actual, emitImmediateSpan: jest.fn(actual.emitImmediateSpan) }; +}); +jest.mock('../../src/telemetry/fallback-ndjson', () => { + const actual = jest.requireActual( + '../../src/telemetry/fallback-ndjson' + ); + return { ...actual, emitNdjsonFallback: jest.fn(actual.emitNdjsonFallback) }; +}); + +import { StateMachineExecutionEngine } from '../../src/state-machine-execution-engine'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { + CheckProvider, + type CheckProviderConfig, + type ExecutionContext, + type ManagedAgentRun, + type ManagedRunCancelReceiptV1, + type ManagedRunCleanupReceiptV1, + type ManagedRunOutcomeV1, + type ManagedRunStartRequest, + type ManagedRunStartedReceiptV1, +} from '../../src/providers/check-provider.interface'; +import type { ManagedRunBindingV1 } from '../../src/state-machine/graph/instance-kernel'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { ReviewSummary } from '../../src/reviewer'; +import type { VisorConfig } from '../../src/types/config'; +import { SandboxManager } from '../../src/sandbox/sandbox-manager'; +import { EventBus } from '../../src/event-bus/event-bus'; +import * as traceHelpers from '../../src/telemetry/trace-helpers'; +import * as ndjsonTelemetry from '../../src/telemetry/fallback-ndjson'; +import * as managedRunHelpers from '../../src/state-machine/dispatch/managed-run'; + +const realManagedRunHelpers = jest.requireActual< + typeof import('../../src/state-machine/dispatch/managed-run') +>('../../src/state-machine/dispatch/managed-run'); +const realTraceHelpers = jest.requireActual( + '../../src/telemetry/trace-helpers' +); +const realNdjsonTelemetry = jest.requireActual< + typeof import('../../src/telemetry/fallback-ndjson') +>('../../src/telemetry/fallback-ndjson'); + +function resetObservableModuleMocks(): void { + jest.mocked(managedRunHelpers.snapshotManagedRun) + .mockReset() + .mockImplementation(realManagedRunHelpers.snapshotManagedRun); + jest.mocked(traceHelpers.emitImmediateSpan) + .mockReset() + .mockImplementation(realTraceHelpers.emitImmediateSpan); + jest.mocked(ndjsonTelemetry.emitNdjsonFallback) + .mockReset() + .mockImplementation(realNdjsonTelemetry.emitNdjsonFallback); +} + +const MANAGED_PROVIDER = 'exp-0123-managed'; +const LEGACY_PROVIDER = 'exp-0123-legacy'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function until(predicate: () => boolean, label: string): Promise { + for (let index = 0; index < 100; index++) { + if (predicate()) return; + await Promise.resolve(); + } + throw new Error(`Deterministic fixture did not reach: ${label}`); +} + +const prInfo = { + number: 1, + title: 'Managed graph-run ownership', + author: 'test', + base: 'main', + head: 'candidate', + files: [], + totalAdditions: 0, + totalDeletions: 0, + eventType: 'manual', +} as PRInfo; + +function fixtureConfig(provider = MANAGED_PROVIDER): VisorConfig { + return { + version: '1.0', + max_parallelism: 1, + workspace: { enabled: false }, + claim_types: { + 'component.catalog@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['components'], + properties: { + components: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'path'], + properties: { + id: { type: 'string', minLength: 1 }, + path: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + }, + }, + 'component.item@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'path'], + properties: { + id: { type: 'string', minLength: 1 }, + path: { type: 'string', minLength: 1 }, + }, + }, + }, + 'component.onboarded@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'findings'], + properties: { + id: { type: 'string', minLength: 1 }, + findings: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + }, + subgraphs: { + 'onboard-component': { + input: { name: 'component', claim: 'component.item@1' }, + checks: { + inspect: { + type: provider, + timeout: 1800000, + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: 'component.onboarded@1', from: 'output' }], + }, + }, + }, + }, + checks: { + 'discover-components': { + type: provider, + emits: [{ claim: 'component.catalog@1', from: 'output' }], + expand: { + claim: 'component.catalog@1', + template: 'onboard-component', + items_pointer: '/components', + key_pointer: '/id', + item_claim: 'component.item@1', + }, + }, + }, + }; +} + +function startedReceipt(binding: ManagedRunBindingV1): ManagedRunStartedReceiptV1 { + return { version: 1, kind: 'started', binding }; +} + +function successOutcome( + binding: ManagedRunBindingV1, + summary: ReviewSummary +): ManagedRunOutcomeV1 { + return { version: 1, kind: 'succeeded', binding, summary }; +} + +function cancelReceipt(binding: ManagedRunBindingV1): ManagedRunCancelReceiptV1 { + return { version: 1, kind: 'cancelled', binding, reason: 'deadline' }; +} + +function cleanupReceipt(binding: ManagedRunBindingV1): ManagedRunCleanupReceiptV1 { + return { + version: 1, + kind: 'cleanup', + binding, + status: 'clean', + activeChildren: 0, + activeResources: 0, + }; +} + +function setManagedTimeout(config: VisorConfig, timeout: number): void { + (config.subgraphs!['onboard-component'].checks.inspect as any).timeout = timeout; +} + +type AcquisitionMode = + | 'valid' + | 'throw' + | 'thenable' + | 'throwing-getter' + | 'null' + | 'missing-member' + | 'extra-member' + | 'wrong-member' + | 'wrong-binding'; + +const IDENTITY_FIELDS = [ + 'sessionId', + 'checkId', + 'scope', + 'managedRunId', + 'nodeInstanceId', + 'nodeGenerationId', + 'attemptId', + 'fence', +] as const; +type IdentityField = (typeof IDENTITY_FIELDS)[number]; +type IdentityPosition = 'handle' | 'started' | 'outcome' | 'cancel' | 'cleanup'; + +function mismatchedBinding( + binding: ManagedRunBindingV1, + field: IdentityField +): ManagedRunBindingV1 { + if (field === 'scope') { + return { + ...binding, + scope: [{ ...binding.scope[0], key: 'wrong-scope' }], + }; + } + if (field === 'fence') { + return { ...binding, fence: binding.fence + 1 }; + } + return { ...binding, [field]: `wrong-${field}` }; +} + +interface ManagedControl { + readonly request: ManagedRunStartRequest; + readonly binding: ManagedRunBindingV1; + readonly started: ReturnType>; + readonly outcome: ReturnType>; + readonly cancel: ReturnType>; + readonly close: ReturnType>; + readonly handle: ManagedAgentRun; + readonly scheduleVisibleAtStart: boolean; + readonly timeoutCallsAtStart: number; + readonly intervalCallsAtStart: number; + cancelCalls: Array<{ reason: 'deadline'; fence: number; receiver: unknown }>; + closeCalls: number; + closeReceivers: unknown[]; +} + +function keyOf(request: ManagedRunStartRequest): string { + const scope = request.binding.scope; + return scope.length === 0 ? 'root' : scope[scope.length - 1].key; +} + +function attemptEvents(engine: StateMachineExecutionEngine, attemptId: string): readonly any[] { + return (engine as any)._lastContext.journal.readRuntimeEvents().filter( + (event: any) => event.attemptId === attemptId || event.binding?.attemptId === attemptId + ); +} + +function expectSerializedControllerBinding( + events: readonly unknown[], + binding: ManagedRunBindingV1 +): void { + const serialized = JSON.parse(JSON.stringify(events)) as Array>; + for (const event of serialized) { + expect(event.sessionId).toBe(binding.sessionId); + expect(event.scope).toEqual(binding.scope); + if ('checkId' in event) expect(event.checkId).toBe(binding.checkId); + if ('nodeInstanceId' in event) expect(event.nodeInstanceId).toBe(binding.nodeInstanceId); + if ('nodeGenerationId' in event) { + expect(event.nodeGenerationId).toBe(binding.nodeGenerationId); + } + if ('attemptId' in event) expect(event.attemptId).toBe(binding.attemptId); + if ('fence' in event) expect(event.fence).toBe(binding.fence); + if ('binding' in event) expect(event.binding).toEqual(binding); + } +} + +describe('EXP-0123 managed graph-run ownership', () => { + const registry = CheckProviderRegistry.getInstance(); + let engine: StateMachineExecutionEngine; + let catalogs: Array>; + let catalogCalls: number; + let controls: ManagedControl[]; + let acquisitionMode: AcquisitionMode; + let startManagedCalls: number; + let originalCancelCalls: number; + let originalCloseCalls: number; + let replacementCancelCalls: number; + let replacementCloseCalls: number; + let activeHandles: number; + let launchOrder: string[]; + let terminalVisibleAtCompletion: boolean[]; + let timeoutCallCount: () => number; + let intervalCallCount: () => number; + let identityPosition: IdentityPosition | undefined; + let observationLane: string[] | undefined; + let legacyInspectResult: ReviewSummary | undefined; + let legacyIntervalCallsAtInspectStart: number; + let mutateRequestAtStart: boolean; + let requestMutationEvidence: Record | undefined; + let callerExecutionContext: ExecutionContext; + let poisonProviderStartPromises: + ((started: Promise, outcome: Promise) => void) | undefined; + let poisonProviderSettlementPromise: ((promise: Promise) => void) | undefined; + + class ManagedFixtureProvider extends CheckProvider { + getName() { + return MANAGED_PROVIDER; + } + getDescription() { + return 'EXP-0123 deterministic managed provider'; + } + async validateConfig() { + return true; + } + async isAvailable() { + return true; + } + getRequirements() { + return []; + } + getSupportedConfigKeys() { + return ['type']; + } + async execute( + _pr: PRInfo, + config: CheckProviderConfig + ): Promise { + const checkId = String(config.checkName); + if (checkId !== 'discover-components') { + throw new Error('MANAGED_GENERATED_EXECUTE_MUST_NOT_RUN'); + } + const index = catalogCalls++; + launchOrder.push(`catalog:${index}`); + return { + issues: [], + output: { components: catalogs[Math.min(index, catalogs.length - 1)] }, + }; + } + startManaged(request: ManagedRunStartRequest): ManagedAgentRun { + startManagedCalls++; + observationLane?.push(`provider:start:${keyOf(request)}`); + if (acquisitionMode === 'throw') throw new Error('RAW_START_SECRET'); + if (acquisitionMode === 'thenable') { + return Promise.resolve({}) as unknown as ManagedAgentRun; + } + if (acquisitionMode === 'null') return null as unknown as ManagedAgentRun; + if (acquisitionMode === 'throwing-getter') { + const bad: Record = {}; + Object.defineProperties(bad, { + binding: { enumerable: true, get: () => { throw new Error('RAW_GETTER_SECRET'); } }, + started: { enumerable: true, value: Promise.resolve() }, + outcome: { enumerable: true, value: Promise.resolve() }, + cancel: { enumerable: true, value: () => Promise.resolve() }, + close: { enumerable: true, value: () => Promise.resolve() }, + }); + return bad as unknown as ManagedAgentRun; + } + if (acquisitionMode === 'missing-member') { + return { + binding: request.binding, + started: Promise.resolve(startedReceipt(request.binding)), + outcome: Promise.resolve(successOutcome(request.binding, { issues: [] })), + cancel: () => Promise.resolve(cancelReceipt(request.binding)), + } as unknown as ManagedAgentRun; + } + if (acquisitionMode === 'extra-member') { + return { + binding: request.binding, + started: Promise.resolve(startedReceipt(request.binding)), + outcome: Promise.resolve(successOutcome(request.binding, { issues: [] })), + cancel: () => Promise.resolve(cancelReceipt(request.binding)), + close: () => Promise.resolve(cleanupReceipt(request.binding)), + unexpected: 'RAW_EXTRA_HANDLE_MEMBER', + } as unknown as ManagedAgentRun; + } + if (acquisitionMode === 'wrong-member') { + return { + binding: request.binding, + started: Promise.resolve(startedReceipt(request.binding)), + outcome: Promise.resolve(successOutcome(request.binding, { issues: [] })), + cancel: () => Promise.resolve(cancelReceipt(request.binding)), + close: 42, + } as unknown as ManagedAgentRun; + } + + const identityField = IDENTITY_FIELDS.find(field => field === keyOf(request)); + const binding = acquisitionMode === 'wrong-binding' + ? { ...request.binding, checkId: 'wrong-check' } + : identityPosition === 'handle' && identityField + ? mismatchedBinding(request.binding, identityField) + : request.binding; + const started = deferred(); + const outcome = deferred(); + const cancel = deferred(); + const close = deferred(); + if (mutateRequestAtStart) { + const map = request.dependencyResults; + const dependencyKey = Array.from(map.keys())[0]; + const dependency = map.get(dependencyKey); + const forEachRows: Array<[string, ReviewSummary, boolean]> = []; + map.forEach((value, key, owner) => forEachRows.push([key, value, owner === map])); + const mutationAttempts = [ + () => ((request.prInfo.files[0] as any).filename = 'provider-mutated.ts'), + () => (((request.checkConfig as any).emits[0] as any).claim = 'provider-mutated@1'), + () => (((request.executionContext.metadata as any).nested as any).label = 'provider-mutated'), + () => ((request.binding.scope[0] as any).key = 'provider-mutated'), + () => ((dependency!.output as any).id = 'provider-mutated'), + ]; + for (const mutate of mutationAttempts) { + try { mutate(); } catch {} + } + const mutatorAttempts: Record = {}; + for (const [name, args] of [ + ['set', ['provider-entry', { issues: [], output: { id: 'provider-entry' } }]], + ['delete', [dependencyKey]], + ['clear', []], + ] as const) { + try { + Reflect.apply((map as any)[name], map, args); + mutatorAttempts[name] = 'returned'; + } catch { + mutatorAttempts[name] = 'rejected'; + } + } + requestMutationEvidence = { + requestFrozen: Object.isFrozen(request), + size: map.size, + dependencyKey, + get: map.get(dependencyKey), + has: map.has(dependencyKey), + entries: Array.from(map.entries()), + keys: Array.from(map.keys()), + values: Array.from(map.values()), + forEachRows, + iteration: Array.from(map), + set: (map as any).set, + delete: (map as any).delete, + clear: (map as any).clear, + mutatorAttempts, + prFilename: request.prInfo.files[0].filename, + emittedClaim: ((request.checkConfig as any).emits[0] as any).claim, + executionLabel: ((request.executionContext.metadata as any).nested as any).label, + scopeKey: request.binding.scope[0].key, + dependencyId: ((map.get(dependencyKey)?.output as any).id as string), + fence: request.binding.fence, + }; + } + let control!: ManagedControl; + const handle: ManagedAgentRun = { + binding, + started: started.promise, + outcome: outcome.promise, + cancel(reason, fence) { + observationLane?.push(`provider:cancel:${keyOf(request)}`); + originalCancelCalls++; + control.cancelCalls.push({ reason, fence, receiver: this }); + poisonProviderSettlementPromise?.(cancel.promise); + return cancel.promise; + }, + close() { + observationLane?.push(`provider:close:${keyOf(request)}`); + originalCloseCalls++; + control.closeCalls++; + control.closeReceivers.push(this); + const observedClose = close.promise.then( + receipt => { + if (acquisitionMode === 'valid' && identityPosition !== 'handle') activeHandles--; + return receipt; + }, + error => { + if (acquisitionMode === 'valid' && identityPosition !== 'handle') activeHandles--; + throw error; + } + ); + poisonProviderSettlementPromise?.(observedClose); + return observedClose; + }, + }; + const journal = (engine as any)._lastContext.journal; + control = { + request, + binding, + started, + outcome, + cancel, + close, + handle, + scheduleVisibleAtStart: journal.readRuntimeEvents().some( + (event: any) => + event.type === 'CheckScheduled' && + event.nodeGenerationId === request.binding.nodeGenerationId + ), + timeoutCallsAtStart: timeoutCallCount(), + intervalCallsAtStart: intervalCallCount(), + cancelCalls: [], + closeCalls: 0, + closeReceivers: [], + }; + controls.push(control); + launchOrder.push(`managed:${keyOf(request)}`); + if (acquisitionMode === 'valid' && identityPosition !== 'handle') activeHandles++; + poisonProviderStartPromises?.(started.promise, outcome.promise); + return handle; + } + } + + class LegacyFixtureProvider extends CheckProvider { + getName() { + return LEGACY_PROVIDER; + } + getDescription() { + return 'EXP-0123 deterministic legacy provider'; + } + async validateConfig() { + return true; + } + async isAvailable() { + return true; + } + getRequirements() { + return []; + } + getSupportedConfigKeys() { + return ['type']; + } + async execute( + _pr: PRInfo, + config: CheckProviderConfig, + _dependencies?: Map, + context?: ExecutionContext + ): Promise { + const checkId = String(config.checkName); + launchOrder.push(`legacy:${checkId}`); + observationLane?.push(`provider:legacy:${checkId}`); + if (checkId === 'discover-components') { + catalogCalls++; + return { issues: [], output: { components: catalogs[0] } }; + } + const consumed = context?.claims?.component || context?.claims?.onboarded; + const key = String((consumed?.payload as { id: string }).id); + legacyIntervalCallsAtInspectStart = intervalCallCount(); + legacyInspectResult = { issues: [], output: { id: key, findings: ['legacy'] } }; + return legacyInspectResult; + } + } + + beforeEach(() => { + resetObservableModuleMocks(); + engine = new StateMachineExecutionEngine(); + catalogs = [[{ id: 'A', path: 'packages/a' }]]; + catalogCalls = 0; + controls = []; + acquisitionMode = 'valid'; + startManagedCalls = 0; + originalCancelCalls = 0; + originalCloseCalls = 0; + replacementCancelCalls = 0; + replacementCloseCalls = 0; + activeHandles = 0; + launchOrder = []; + terminalVisibleAtCompletion = []; + timeoutCallCount = () => 0; + intervalCallCount = () => 0; + identityPosition = undefined; + observationLane = undefined; + legacyInspectResult = undefined; + legacyIntervalCallsAtInspectStart = 0; + mutateRequestAtStart = false; + requestMutationEvidence = undefined; + poisonProviderStartPromises = undefined; + poisonProviderSettlementPromise = undefined; + callerExecutionContext = { + metadata: { nested: { label: 'caller-execution' } }, + hooks: { + onCheckComplete: info => { + if (info.checkId !== 'inspect') return; + const events = (engine as any)._lastContext.journal.readRuntimeEvents(); + terminalVisibleAtCompletion.push( + events.some((event: any) => event.type === 'ManagedRunTerminated') + ); + observationLane?.push(`callback:${info.checkId}`); + }, + }, + }; + engine.setExecutionContext(callerExecutionContext); + registry.register(new ManagedFixtureProvider()); + registry.register(new LegacyFixtureProvider()); + }); + + afterEach(() => { + registry.unregister(MANAGED_PROVIDER); + registry.unregister(LEGACY_PROVIDER); + jest.useRealTimers(); + jest.restoreAllMocks(); + resetObservableModuleMocks(); + }); + + it('holds capacity through close, snapshots handle authority, then runs generated work before catalog work', async () => { + catalogs = [ + [ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ], + [ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ], + ]; + let runSettled = false; + const run = engine + .executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(), + 'table', + false, + 1 + ) + .finally(() => { + runSettled = true; + }); + + await until(() => controls.length === 1, 'first managed acquisition'); + const first = controls[0]; + const firstKey = keyOf(first.request); + expect(['A', 'B']).toContain(firstKey); + expect(first.scheduleVisibleAtStart).toBe(true); + const reconciliation = engine.requestCatalogReconciliation('discover-components'); + + const replacementStarted = deferred(); + const replacementOutcome = deferred(); + Object.assign(first.handle as unknown as Record, { + binding: { ...first.binding, fence: first.binding.fence + 100 }, + started: replacementStarted.promise, + outcome: replacementOutcome.promise, + cancel: () => { + replacementCancelCalls++; + return Promise.resolve(cancelReceipt(first.binding)); + }, + close: () => { + replacementCloseCalls++; + return Promise.resolve(cleanupReceipt(first.binding)); + }, + }); + + first.started.resolve(startedReceipt(first.binding)); + first.outcome.resolve( + successOutcome(first.binding, { + issues: [], + output: { id: firstKey, findings: ['bounded'] }, + }) + ); + await until(() => first.closeCalls === 1, 'first close call'); + + const blockedEvents = attemptEvents(engine, first.binding.attemptId); + expect(blockedEvents.some(event => event.type === 'ManagedRunTerminated')).toBe(false); + expect(blockedEvents.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(controls).toHaveLength(1); + expect(catalogCalls).toBe(1); + expect(runSettled).toBe(false); + expect(activeHandles).toBe(1); + expect(terminalVisibleAtCompletion).toEqual([]); + + first.close.resolve(cleanupReceipt(first.binding)); + await until(() => controls.length === 2, 'second generated acquisition'); + const second = controls[1]; + const secondKey = keyOf(second.request); + expect([firstKey, secondKey].sort()).toEqual(['A', 'B']); + expect(catalogCalls).toBe(1); + + second.started.resolve(startedReceipt(second.binding)); + second.outcome.resolve( + successOutcome(second.binding, { + issues: [], + output: { id: secondKey, findings: ['bounded'] }, + }) + ); + await until(() => second.closeCalls === 1, 'second close call'); + second.close.resolve(cleanupReceipt(second.binding)); + await until(() => catalogCalls === 2, 'queued catalog reconciliation'); + await run; + + expect(launchOrder).toEqual([ + 'catalog:0', + `managed:${firstKey}`, + `managed:${secondKey}`, + 'catalog:1', + ]); + expect(reconciliation.requestId).toEqual(expect.any(String)); + expect(startManagedCalls).toBe(2); + expect(originalCloseCalls).toBe(2); + expect(originalCancelCalls).toBe(0); + expect(replacementCloseCalls).toBe(0); + expect(replacementCancelCalls).toBe(0); + expect(first.closeReceivers).toEqual([first.handle]); + expect(second.closeReceivers).toEqual([second.handle]); + expect(activeHandles).toBe(0); + expect(terminalVisibleAtCompletion).toEqual([true, true]); + + for (const control of controls) { + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunStarted', + 'ManagedRunTerminated', + 'ClaimPublished', + 'AttemptCompleted', + ]); + expect(events.filter(event => event.type === 'ManagedRunAcquired')).toHaveLength(1); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptCompleted')).toHaveLength(1); + expect(events.some(event => event.type === 'AttemptFailed')).toBe(false); + const terminal = events.find(event => event.type === 'ManagedRunTerminated'); + expect(terminal).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'completed', + failureCode: null, + }); + } + + const finalProjection = (engine as any)._lastContext.journal.getInstanceProjection(); + expect(finalProjection.requestsById[reconciliation.requestId].status).toBe('completed'); + expect((engine as any)._lastContext.journal.queryReadyWork()).toEqual([]); + }); + + it('gives the actual provider a complete immutable request and ReadonlyMap view', async () => { + mutateRequestAtStart = true; + const config = fixtureConfig(); + const callerPrInfo = { + ...prInfo, + body: 'caller body', + files: [{ + filename: 'packages/a/index.ts', + additions: 1, + deletions: 0, + changes: 1, + status: 'modified' as const, + }], + } as PRInfo; + const configBefore = JSON.stringify(config); + const prInfoBefore = JSON.stringify(callerPrInfo); + const executionContextBefore = JSON.stringify(callerExecutionContext); + const run = engine.executeGroupedChecks( + callerPrInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'provider request mutation attempt'); + const control = controls[0]; + const dependencySummary = { + issues: [], + output: { id: 'A', path: 'packages/a' }, + }; + + expect(requestMutationEvidence).toMatchObject({ + requestFrozen: true, + size: 1, + dependencyKey: 'discover-components', + get: dependencySummary, + has: true, + entries: [['discover-components', dependencySummary]], + keys: ['discover-components'], + values: [dependencySummary], + forEachRows: [['discover-components', dependencySummary, true]], + iteration: [['discover-components', dependencySummary]], + set: undefined, + delete: undefined, + clear: undefined, + mutatorAttempts: { set: 'rejected', delete: 'rejected', clear: 'rejected' }, + prFilename: 'packages/a/index.ts', + emittedClaim: 'component.onboarded@1', + executionLabel: 'caller-execution', + scopeKey: 'A', + dependencyId: 'A', + fence: control.request.binding.fence, + }); + expect(JSON.stringify(config)).toBe(configBefore); + expect(JSON.stringify(callerPrInfo)).toBe(prInfoBefore); + expect(JSON.stringify(callerExecutionContext)).toBe(executionContextBefore); + expect(Object.isFrozen(config)).toBe(false); + expect(Object.isFrozen(config.subgraphs!['onboard-component'].checks.inspect)).toBe(false); + expect(Object.isFrozen(callerPrInfo)).toBe(false); + expect(Object.isFrozen(callerPrInfo.files[0])).toBe(false); + expect(Object.isFrozen(callerExecutionContext)).toBe(false); + expect(Object.isFrozen(callerExecutionContext.metadata)).toBe(false); + expect(control.request.binding.scope[0].key).toBe('A'); + + control.started.resolve(startedReceipt(control.request.binding)); + control.outcome.resolve( + successOutcome(control.request.binding, { + issues: [], + output: { id: 'A', findings: ['immutable request'] }, + }) + ); + await until(() => control.closeCalls === 1, 'immutable request close'); + const cleanup = cleanupReceipt(control.request.binding); + control.close.resolve(cleanup); + await run; + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + binding: control.binding, + cleanupStatus: 'clean', + controllerDecision: 'completed', + failureCode: null, + }); + expect(cleanup.binding).toEqual(control.binding); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + }); + + it('replays only journal facts without crossing live collaborator boundaries', async () => { + let eventBus!: EventBus; + const originalBuild = (engine as any).buildEngineContext.bind(engine); + jest.spyOn(engine as any, 'buildEngineContext').mockImplementation((...args: unknown[]) => { + const built = originalBuild(...args); + eventBus = new EventBus(); + built.eventBus = eventBus; + return built; + }); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(), + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'replay fixture acquisition'); + const control = controls[0]; + control.started.resolve(startedReceipt(control.binding)); + control.outcome.resolve( + successOutcome(control.binding, { + issues: [], + output: { id: 'A', findings: ['replay facts only'] }, + }) + ); + await until(() => control.closeCalls === 1, 'replay fixture close'); + control.close.resolve(cleanupReceipt(control.binding)); + await run; + + const provider = registry.getProviderOrThrow(MANAGED_PROVIDER) as ManagedFixtureProvider; + const providerSpy = jest.spyOn(provider, 'startManaged').mockImplementation(() => { + throw new Error('REPLAY_MUST_NOT_START_PROVIDER'); + }); + const helperSpy = jest.mocked(managedRunHelpers.snapshotManagedRun); + expect(helperSpy).toHaveBeenCalledTimes(1); + helperSpy.mockClear(); + helperSpy.mockImplementation(() => { + throw new Error('REPLAY_MUST_NOT_SNAPSHOT_HANDLE'); + }); + const cancelSpy = jest.spyOn(control.handle, 'cancel').mockImplementation(() => { + throw new Error('REPLAY_MUST_NOT_CANCEL_HANDLE'); + }); + const closeSpy = jest.spyOn(control.handle, 'close').mockImplementation(() => { + throw new Error('REPLAY_MUST_NOT_CLOSE_HANDLE'); + }); + const observerSpy = jest.fn(() => { + throw new Error('REPLAY_MUST_NOT_NOTIFY_OBSERVERS'); + }); + eventBus.onAny(observerSpy); + + const journal = (engine as any)._lastContext.journal; + const liveProjection = journal.getInstanceProjection(); + expect(journal.replayInstanceProjection()).toEqual(liveProjection); + expect(providerSpy).not.toHaveBeenCalled(); + expect(helperSpy).not.toHaveBeenCalled(); + expect(cancelSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + expect(observerSpy).not.toHaveBeenCalled(); + expect(activeHandles).toBe(0); + }); + + it('keeps the committed Started fact authoritative when its observation throws', async () => { + jest.mocked(traceHelpers.emitImmediateSpan).mockImplementation((name: string) => { + if (name === 'visor.check.inspect.started') { + throw new Error('TEST_STARTED_OBSERVATION_FAILURE'); + } + }); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(), + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'Started observation acquisition'); + const control = controls[0]; + control.started.resolve(startedReceipt(control.request.binding)); + await until( + () => attemptEvents(engine, control.request.binding.attemptId).some( + event => event.type === 'ManagedRunStarted' + ), + 'Started fact before failed observation' + ); + control.outcome.resolve( + successOutcome(control.request.binding, { + issues: [], + output: { id: 'A', findings: ['started fact retained'] }, + }) + ); + await until(() => control.closeCalls === 1, 'Started observation close'); + control.close.resolve(cleanupReceipt(control.request.binding)); + await run; + + const events = attemptEvents(engine, control.request.binding.attemptId); + expect(events.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunStarted', + 'ManagedRunTerminated', + 'ClaimPublished', + 'AttemptCompleted', + ]); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'completed', + failureCode: null, + }); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + }); + + it('keeps one deadline armed through close and starts cancel and cleanup independently', async () => { + jest.useFakeTimers(); + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + const intervalSpy = jest.spyOn(global, 'setInterval'); + timeoutCallCount = () => timeoutSpy.mock.calls.length; + intervalCallCount = () => intervalSpy.mock.calls.length; + + const config = fixtureConfig(); + setManagedTimeout(config, 25); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'deadline fixture acquisition'); + const control = controls[0]; + await until( + () => timeoutSpy.mock.calls.length === control.timeoutCallsAtStart + 1, + 'one managed deadline' + ); + expect(intervalSpy.mock.calls.length).toBe(control.intervalCallsAtStart); + + control.started.resolve(startedReceipt(control.binding)); + control.outcome.resolve( + successOutcome(control.binding, { + issues: [], + output: { id: 'A', findings: ['late close'] }, + }) + ); + await until(() => control.closeCalls === 1, 'ordinary close pending'); + expect(jest.getTimerCount()).toBe(1); + + const redirectedCancel = () => { + replacementCancelCalls++; + return Promise.resolve(cancelReceipt(control.request.binding)); + }; + const redirectedClose = () => { + replacementCloseCalls++; + return Promise.resolve(cleanupReceipt(control.request.binding)); + }; + Object.assign(control.handle as unknown as Record, { + cancel: redirectedCancel, + close: redirectedClose, + }); + Object.setPrototypeOf(control.handle, Object.freeze({ + cancel: redirectedCancel, + close: redirectedClose, + })); + + jest.advanceTimersByTime(25); + await until(() => control.cancelCalls.length === 1, 'deadline cancellation'); + expect(control.cancelCalls).toEqual([ + { reason: 'deadline', fence: control.binding.fence, receiver: control.handle }, + ]); + expect(control.closeCalls).toBe(1); + + control.close.resolve(cleanupReceipt(control.binding)); + await Promise.resolve(); + expect( + attemptEvents(engine, control.binding.attemptId).some( + event => event.type === 'ManagedRunTerminated' + ) + ).toBe(false); + expect(activeHandles).toBe(0); + + control.cancel.resolve(cancelReceipt(control.binding)); + await run; + + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunStarted', + 'ManagedRunCancelRequested', + 'ManagedRunTerminated', + 'AttemptFailed', + ]); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + expect(events.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(originalCancelCalls).toBe(1); + expect(originalCloseCalls).toBe(1); + expect(replacementCancelCalls).toBe(0); + expect(replacementCloseCalls).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('coordinates managed promises without native combinators or late promise lookups', async () => { + const ControllerPromise = Promise; + const thenDescriptor = Object.getOwnPropertyDescriptor(ControllerPromise.prototype, 'then'); + const raceDescriptor = Object.getOwnPropertyDescriptor(ControllerPromise, 'race'); + const allSettledDescriptor = Object.getOwnPropertyDescriptor( + ControllerPromise, + 'allSettled' + ); + if ( + !raceDescriptor || + typeof raceDescriptor.value !== 'function' || + !allSettledDescriptor || + typeof allSettledDescriptor.value !== 'function' + ) { + throw new Error('PROMISE_SENTINEL_DESCRIPTOR_UNAVAILABLE'); + } + const capturedRace = raceDescriptor.value as typeof Promise.race; + const capturedAllSettled = allSettledDescriptor.value as typeof Promise.allSettled; + const realQueueMicrotask = global.queueMicrotask.bind(global); + const counters = { + managedRaceThen: 0, + lateStaticRace: 0, + lateStaticAllSettled: 0, + }; + const poisonedProviderStartPromises: Promise[] = []; + const managedRaceThenTrap = jest.fn(() => { + counters.managedRaceThen++; + throw new Error('PROVIDER_START_PROMISE_METHOD_MUST_NOT_BE_CONSULTED'); + }); + const armStaticProbe = ( + property: 'race' | 'allSettled', + descriptor: PropertyDescriptor, + captured: Function, + counter: 'lateStaticRace' | 'lateStaticAllSettled' + ): (() => void) => { + const sentinel = function (this: PromiseConstructor, ...args: unknown[]) { + counters[counter]++; + return Reflect.apply(captured, this, args); + }; + Object.defineProperty(ControllerPromise, property, { ...descriptor, value: sentinel }); + let restored = false; + return () => { + if (restored) return; + restored = true; + const current = Object.getOwnPropertyDescriptor(ControllerPromise, property); + if (current?.value === sentinel) { + Object.defineProperty(ControllerPromise, property, descriptor); + } + }; + }; + const restoreExactDescriptors = () => { + Object.defineProperty(ControllerPromise, 'race', raceDescriptor); + Object.defineProperty(ControllerPromise, 'allSettled', allSettledDescriptor); + }; + + let run: Promise | undefined; + let firstReachedTerminal = false; + let firstReachedObserver = false; + jest.useFakeTimers(); + try { + catalogs = [[ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ]]; + const config = fixtureConfig(); + config.max_parallelism = 2; + setManagedTimeout(config, 25); + + poisonProviderStartPromises = (started, outcome) => { + for (const promise of [started, outcome]) { + Object.defineProperties(promise, { + then: { configurable: true, value: managedRaceThenTrap }, + catch: { configurable: true, value: managedRaceThenTrap }, + }); + poisonedProviderStartPromises.push(promise); + } + }; + run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 2 + ); + await until(() => controls.length === 2, 'combinator sentinel acquisitions'); + poisonProviderStartPromises = undefined; + expect(poisonedProviderStartPromises).toHaveLength(4); + for (const [index, control] of controls.entries()) { + expect(poisonedProviderStartPromises[index * 2]).toBe(control.started.promise); + expect(poisonedProviderStartPromises[index * 2 + 1]).toBe(control.outcome.promise); + } + for (const promise of poisonedProviderStartPromises) { + expect(Object.getOwnPropertyDescriptor(promise, 'then')?.value).toBe( + managedRaceThenTrap + ); + expect(Object.getOwnPropertyDescriptor(promise, 'catch')?.value).toBe( + managedRaceThenTrap + ); + } + + const providerPromiseTrap = jest.fn(() => { + throw new Error('PROVIDER_PROMISE_METHOD_MUST_NOT_BE_CONSULTED'); + }); + poisonProviderSettlementPromise = promise => { + Object.defineProperties(promise, { + then: { configurable: true, value: providerPromiseTrap }, + catch: { configurable: true, value: providerPromiseTrap }, + }); + }; + const restoreStaticAllSettled = armStaticProbe( + 'allSettled', + allSettledDescriptor, + capturedAllSettled, + 'lateStaticAllSettled' + ); + jest.advanceTimersByTime(25); + restoreStaticAllSettled(); + await until( + () => controls.every(control => control.cancelCalls.length === 1 && control.closeCalls === 1), + 'combinator sentinel deadline calls' + ); + + const first = controls[0]; + const second = controls[1]; + const restoreStaticRace = armStaticProbe( + 'race', + raceDescriptor, + capturedRace, + 'lateStaticRace' + ); + first.cancel.resolve(cancelReceipt(first.binding)); + first.close.resolve(cleanupReceipt(first.binding)); + for (let index = 0; index < 10; index++) { + await new ControllerPromise(resolve => realQueueMicrotask(resolve)); + } + firstReachedTerminal = attemptEvents(engine, first.binding.attemptId).some( + event => event.type === 'ManagedRunTerminated' + ); + firstReachedObserver = ((engine as any)._lastRunner.getState().historyLog as readonly any[]) + .some(event => event.type === 'CheckErrored' && event.checkId === 'inspect'); + restoreStaticRace(); + + second.cancel.resolve(cancelReceipt(second.binding)); + second.close.resolve(cleanupReceipt(second.binding)); + await run; + + for (const control of controls) { + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunCancelRequested', + 'ManagedRunTerminated', + 'AttemptFailed', + ]); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + binding: control.binding, + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(events.find(event => event.type === 'AttemptFailed')).toMatchObject({ + reason: 'MANAGED_DEADLINE_EXCEEDED', + }); + expect(control.cancelCalls).toEqual([{ + reason: 'deadline', + fence: control.binding.fence, + receiver: control.handle, + }]); + expect(control.closeCalls).toBe(1); + expect(control.closeReceivers).toEqual([control.handle]); + } + expect(firstReachedTerminal).toBe(true); + expect(firstReachedObserver).toBe(true); + expect(counters).toEqual({ + managedRaceThen: 0, + lateStaticRace: 0, + lateStaticAllSettled: 0, + }); + expect(managedRaceThenTrap).not.toHaveBeenCalled(); + expect(providerPromiseTrap).not.toHaveBeenCalled(); + expect(originalCancelCalls).toBe(2); + expect(originalCloseCalls).toBe(2); + expect(activeHandles).toBe(0); + expect(jest.getTimerCount()).toBe(0); + } finally { + restoreExactDescriptors(); + poisonProviderStartPromises = undefined; + poisonProviderSettlementPromise = undefined; + for (const control of controls) { + control.started.resolve(startedReceipt(control.binding)); + control.outcome.resolve( + successOutcome(control.binding, { + issues: [], + output: { id: keyOf(control.request), findings: ['sentinel-finally'] }, + }) + ); + control.cancel.resolve(cancelReceipt(control.binding)); + control.close.resolve(cleanupReceipt(control.binding)); + } + if (run) { + try { await run; } catch {} + } + jest.useRealTimers(); + } + + expect(Object.getOwnPropertyDescriptor(ControllerPromise.prototype, 'then')).toEqual( + thenDescriptor + ); + expect(Object.getOwnPropertyDescriptor(ControllerPromise, 'race')).toEqual(raceDescriptor); + expect(Object.getOwnPropertyDescriptor(ControllerPromise, 'allSettled')).toEqual( + allSettledDescriptor + ); + }); + + it.each([ + { name: 'negative', timeout: -1 }, + { name: 'positive infinity', timeout: Number.POSITIVE_INFINITY }, + ])('normalizes a $name managed timeout to one immediate owned deadline', async row => { + jest.useFakeTimers(); + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + const intervalSpy = jest.spyOn(global, 'setInterval'); + timeoutCallCount = () => timeoutSpy.mock.calls.length; + intervalCallCount = () => intervalSpy.mock.calls.length; + const config = fixtureConfig(); + setManagedTimeout(config, row.name === 'positive infinity' ? 25 : row.timeout); + if (row.name === 'positive infinity') { + const originalBuild = (engine as any).buildEngineContext.bind(engine); + jest.spyOn(engine as any, 'buildEngineContext').mockImplementation((...args: unknown[]) => { + const built = originalBuild(...args); + const originalGetGeneratedExecution = built.journal.getGeneratedExecution.bind( + built.journal + ); + built.journal.getGeneratedExecution = (nodeGenerationId: string) => { + const execution = originalGetGeneratedExecution(nodeGenerationId); + return execution.node.templateNodeKey === 'inspect' + ? { + ...execution, + node: { + ...execution.node, + check: { ...execution.node.check, timeout: Number.POSITIVE_INFINITY }, + }, + } + : execution; + }; + return built; + }); + } + + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, `${row.name} timeout acquisition`); + const control = controls[0]; + await until( + () => timeoutSpy.mock.calls.length === control.timeoutCallsAtStart + 1, + `${row.name} single deadline` + ); + expect(timeoutSpy.mock.calls.at(-1)?.[1]).toBe(0); + expect(intervalSpy.mock.calls.length).toBe(control.intervalCallsAtStart); + + jest.advanceTimersByTime(0); + await until( + () => control.cancelCalls.length === 1 && control.closeCalls === 1, + `${row.name} cancel and close` + ); + expect(control.cancelCalls[0]).toMatchObject({ + reason: 'deadline', + fence: control.request.binding.fence, + }); + control.cancel.resolve(cancelReceipt(control.request.binding)); + control.close.resolve(cleanupReceipt(control.request.binding)); + await run; + + const events = attemptEvents(engine, control.request.binding.attemptId); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(JSON.stringify(events)).not.toContain(String(row.timeout)); + expect(originalCancelCalls).toBe(1); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it.each<{ + name: string; + mode: AcquisitionMode; + failureCode: string; + configure?: (config: VisorConfig) => void; + startCalls: number; + }>([ + { name: 'start throw', mode: 'throw', failureCode: 'MANAGED_START_FAILED', startCalls: 1 }, + { name: 'thenable', mode: 'thenable', failureCode: 'MANAGED_HANDLE_INVALID', startCalls: 1 }, + { + name: 'throwing getter', + mode: 'throwing-getter', + failureCode: 'MANAGED_HANDLE_INVALID', + startCalls: 1, + }, + { name: 'null handle', mode: 'null', failureCode: 'MANAGED_HANDLE_INVALID', startCalls: 1 }, + { + name: 'missing member', + mode: 'missing-member', + failureCode: 'MANAGED_HANDLE_INVALID', + startCalls: 1, + }, + { + name: 'extra handle member', + mode: 'extra-member', + failureCode: 'MANAGED_HANDLE_INVALID', + startCalls: 1, + }, + { + name: 'wrong member', + mode: 'wrong-member', + failureCode: 'MANAGED_HANDLE_INVALID', + startCalls: 1, + }, + { + name: 'wrong binding', + mode: 'wrong-binding', + failureCode: 'MANAGED_BINDING_MISMATCH', + startCalls: 1, + }, + { + name: 'managed debounce', + mode: 'valid', + failureCode: 'MANAGED_DEBOUNCE_UNSUPPORTED', + configure: config => { + (config.subgraphs!['onboard-component'].checks.inspect as any).debounce = 5; + }, + startCalls: 0, + }, + { + name: 'managed sandbox', + mode: 'valid', + failureCode: 'MANAGED_SANDBOX_UNSUPPORTED', + configure: config => { + config.sandboxes = { fixture: { image: 'unused' } }; + (config.subgraphs!['onboard-component'].checks.inspect as any).sandbox = 'fixture'; + }, + startCalls: 0, + }, + ])('atomically fails acquisition for $name', async row => { + acquisitionMode = row.mode; + const config = fixtureConfig(); + row.configure?.(config); + let buildContextSpy: { mockRestore: () => void } | undefined; + if (row.name === 'managed sandbox') { + delete config.sandboxes; + const originalBuild = (engine as any).buildEngineContext.bind(engine); + buildContextSpy = jest.spyOn(engine as any, 'buildEngineContext').mockImplementation( + (...args: unknown[]) => { + const built = originalBuild(...args); + const fakeSandbox = Object.create(SandboxManager.prototype) as SandboxManager; + Object.defineProperty(fakeSandbox, 'resolveSandbox', { + value: jest.fn((requested?: string) => requested === 'fixture' ? 'fixture' : null), + }); + built.sandboxManager = fakeSandbox; + return built; + } + ); + } + try { + await engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + } finally { + buildContextSpy?.mockRestore(); + } + + const events = (engine as any)._lastContext.journal.readRuntimeEvents() as readonly any[]; + const generated = events.filter( + event => event.checkId === 'inspect' || event.binding?.checkId === 'inspect' + ); + expect(generated.map(event => event.type)).toEqual([ + 'NodeGenerationActivated', + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquisitionFailed', + 'AttemptFailed', + ]); + expect(generated.filter(event => event.type === 'CheckScheduled')).toHaveLength(1); + expect(generated.find(event => event.type === 'ManagedRunAcquisitionFailed')).toMatchObject({ + failureCode: row.failureCode, + }); + expect(generated.find(event => event.type === 'AttemptFailed')).toMatchObject({ + reason: row.failureCode, + }); + expect(generated.some(event => event.type === 'ManagedRunAcquired')).toBe(false); + expect(generated.some(event => event.type === 'ManagedRunTerminated')).toBe(false); + expect(generated.some(event => event.type === 'AttemptCompleted')).toBe(false); + expect(startManagedCalls).toBe(row.startCalls); + expect(originalCancelCalls).toBe(0); + expect(originalCloseCalls).toBe(0); + expect(activeHandles).toBe(0); + expect(JSON.stringify(generated)).not.toContain('RAW_START_SECRET'); + expect(JSON.stringify(generated)).not.toContain('RAW_GETTER_SECRET'); + }); + + it.each(['handle', 'started', 'outcome', 'cancel', 'cleanup'])( + 'rejects every independently mismatched binding field in the %s position', + async position => { + identityPosition = position; + catalogs = [IDENTITY_FIELDS.map(field => ({ id: field, path: `packages/${field}` }))]; + const config = fixtureConfig(); + config.max_parallelism = 8; + if (position === 'cancel') { + jest.useFakeTimers(); + setManagedTimeout(config, 25); + } + + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 8 + ); + await until(() => controls.length === 8, `${position} identity acquisitions`); + + if (position !== 'handle') { + for (const control of controls) { + const field = keyOf(control.request) as IdentityField; + const expected = control.request.binding; + if (position === 'started') { + control.started.resolve(startedReceipt(mismatchedBinding(expected, field))); + continue; + } + + control.started.resolve(startedReceipt(expected)); + if (position === 'outcome') { + control.outcome.resolve( + successOutcome(mismatchedBinding(expected, field), { + issues: [], + output: { id: field, findings: ['wrong outcome binding'] }, + }) + ); + } else if (position !== 'cancel') { + control.outcome.resolve( + successOutcome(expected, { + issues: [], + output: { id: field, findings: ['wrong cleanup binding'] }, + }) + ); + } + } + + if (position === 'cancel') { + jest.advanceTimersByTime(25); + await until( + () => controls.every(control => control.cancelCalls.length === 1 && control.closeCalls === 1), + 'all identity cancel/close calls' + ); + for (const control of controls) { + const field = keyOf(control.request) as IdentityField; + control.cancel.resolve(cancelReceipt(mismatchedBinding(control.request.binding, field))); + control.close.resolve(cleanupReceipt(control.request.binding)); + } + } else { + await until( + () => controls.every(control => control.closeCalls === 1), + `all ${position} close calls` + ); + for (const control of controls) { + const field = keyOf(control.request) as IdentityField; + control.close.resolve( + position === 'cleanup' + ? cleanupReceipt(mismatchedBinding(control.request.binding, field)) + : cleanupReceipt(control.request.binding) + ); + } + } + } + + await run; + const expectedCode = position === 'handle' + ? 'MANAGED_BINDING_MISMATCH' + : position === 'started' + ? 'MANAGED_STARTED_RECEIPT_INVALID' + : position === 'outcome' + ? 'MANAGED_OUTCOME_RECEIPT_INVALID' + : position === 'cancel' + ? 'MANAGED_CANCEL_RECEIPT_INVALID' + : 'MANAGED_CLEANUP_RECEIPT_INVALID'; + + expect(startManagedCalls).toBe(8); + expect(originalCloseCalls).toBe(position === 'handle' ? 0 : 8); + expect(originalCancelCalls).toBe(position === 'cancel' ? 8 : 0); + expect(activeHandles).toBe(0); + for (const control of controls) { + const events = attemptEvents(engine, control.request.binding.attemptId); + expectSerializedControllerBinding(events, control.request.binding); + if (position === 'handle') { + expect(events.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquisitionFailed', + 'AttemptFailed', + ]); + expect(events.find(event => event.type === 'ManagedRunAcquisitionFailed')).toMatchObject({ + failureCode: expectedCode, + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(0); + } else { + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + controllerDecision: 'failed', + failureCode: expectedCode, + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(events.some(event => event.type === 'AttemptCompleted')).toBe(false); + expect(events.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(control.closeCalls).toBe(1); + } + expect(JSON.stringify(events)).not.toContain('wrong-'); + } + } + ); + + it.each<{ + name: string; + failureCode: string; + configure?: (config: VisorConfig) => void; + summary: ReviewSummary; + observer: 'completed' | 'errored'; + }>([ + { + name: 'fatal summary', + failureCode: 'MANAGED_FATAL_SUMMARY', + summary: { + issues: [ + { + file: 'fixture.ts', + line: 1, + ruleId: 'fixture/error', + message: 'fatal fixture', + severity: 'error', + category: 'logic', + }, + ], + output: { id: 'A', findings: ['fatal'] }, + }, + observer: 'completed', + }, + { + name: 'fail_if', + failureCode: 'MANAGED_FAIL_IF', + configure: config => { + (config.subgraphs!['onboard-component'].checks.inspect as any).fail_if = 'true'; + }, + summary: { issues: [], output: { id: 'A', findings: ['fail_if'] } }, + observer: 'completed', + }, + { + name: 'failure_conditions', + failureCode: 'MANAGED_FAIL_IF', + configure: config => { + (config.subgraphs!['onboard-component'].checks.inspect as any).failure_conditions = { + fixture: 'true', + }; + }, + summary: { issues: [], output: { id: 'A', findings: ['condition'] } }, + observer: 'completed', + }, + { + name: 'halt_execution', + failureCode: 'MANAGED_HALT_EXECUTION', + configure: config => { + (config.subgraphs!['onboard-component'].checks.inspect as any).failure_conditions = { + fixture: { + condition: 'true', + message: 'halt fixture', + severity: 'error', + halt_execution: true, + }, + }; + }, + summary: { issues: [], output: { id: 'A', findings: ['halt'] } }, + observer: 'completed', + }, + { + name: 'claim validation', + failureCode: 'MANAGED_CLAIM_VALIDATION_FAILED', + summary: { issues: [], output: { id: 'A' } }, + observer: 'errored', + }, + ])('keeps clean cleanup separate from $name controller failure', async row => { + const config = fixtureConfig(); + row.configure?.(config); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, `${row.name} acquisition`); + const control = controls[0]; + control.started.resolve(startedReceipt(control.binding)); + control.outcome.resolve(successOutcome(control.binding, row.summary)); + await until(() => control.closeCalls === 1, `${row.name} close`); + control.close.resolve(cleanupReceipt(control.binding)); + await run; + + const events = attemptEvents(engine, control.binding.attemptId); + const terminalIndex = events.findIndex(event => event.type === 'ManagedRunTerminated'); + expect(terminalIndex).toBeGreaterThan(0); + expect(events[terminalIndex]).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: row.failureCode, + }); + expect(events[terminalIndex + 1]).toMatchObject({ + type: 'AttemptFailed', + reason: row.failureCode, + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(events.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(events.some(event => event.type === 'AttemptCompleted')).toBe(false); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + + const history = (engine as any)._lastRunner.getState().historyLog as readonly any[]; + expect( + history.some( + event => + event.checkId === 'inspect' && + event.type === (row.observer === 'completed' ? 'CheckCompleted' : 'CheckErrored') + ) + ).toBe(true); + if (row.name === 'halt_execution') { + expect(history.some(event => event.type === 'Shutdown')).toBe(true); + expect(history.some(event => event.type === 'StateTransition' && event.to === 'Error')).toBe( + true + ); + } + if (row.observer === 'completed') expect(terminalVisibleAtCompletion).toEqual([true]); + }); + + it('latches halt scheduling immediately after terminal when the routing effect throws', async () => { + catalogs = [[ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ]]; + const config = fixtureConfig(); + (config.subgraphs!['onboard-component'].checks.inspect as any).failure_conditions = { + fixture: { + condition: 'true', + message: 'halt before fallible effect', + severity: 'error', + halt_execution: true, + }, + }; + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'halt latch acquisition'); + const control = controls[0]; + const history = (engine as any)._lastRunner.getState().historyLog as any[]; + let shutdownThrows = 0; + Object.defineProperty(history, 'push', { + configurable: true, + value: function (this: any[], ...items: any[]) { + if (shutdownThrows === 0 && items.some(item => item.type === 'Shutdown')) { + shutdownThrows++; + throw new Error('TEST_HALT_EFFECT_OBSERVER_FAILURE'); + } + return Array.prototype.push.apply(this, items); + }, + }); + control.started.resolve(startedReceipt(control.request.binding)); + control.outcome.resolve( + successOutcome(control.request.binding, { + issues: [], + output: { id: keyOf(control.request), findings: ['halted'] }, + }) + ); + await until(() => control.closeCalls === 1, 'halt latch close'); + control.close.resolve(cleanupReceipt(control.request.binding)); + await run; + + const events = attemptEvents(engine, control.request.binding.attemptId); + expect(shutdownThrows).toBe(1); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_HALT_EXECUTION', + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(controls).toHaveLength(1); + expect( + (engine as any)._lastContext.journal.queryReadyWork().filter( + (generation: any) => generation.checkId === 'inspect' + ) + ).toHaveLength(1); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + }); + + it('converges a reachable post-provider routing throw through the managed terminal latch', async () => { + const config = fixtureConfig(); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'post-provider throw acquisition'); + const control = controls[0]; + const journal = (engine as any)._lastContext.journal; + const plainFailSpy = jest.spyOn(journal, 'failGeneratedAttempt'); + Object.defineProperty((engine as any)._lastContext.config, 'fail_if', { + configurable: true, + get: () => { + throw new Error('TEST_POST_PROVIDER_ROUTING_FAILURE'); + }, + }); + + control.started.resolve(startedReceipt(control.request.binding)); + control.outcome.resolve( + successOutcome(control.request.binding, { + issues: [], + output: { id: 'A', findings: ['routing throw'] }, + }) + ); + await until(() => control.closeCalls === 1, 'post-provider clean close'); + control.close.resolve(cleanupReceipt(control.request.binding)); + await run; + + const events = attemptEvents(engine, control.request.binding.attemptId); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_POST_PROVIDER_FAILED', + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(events.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(events.some(event => event.type === 'AttemptCompleted')).toBe(false); + expect(plainFailSpy).not.toHaveBeenCalled(); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + }); + + it('keeps an acquired failure terminal when the outer CheckErrored observer throws', async () => { + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(), + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'outer observer acquisition'); + const control = controls[0]; + const journal = (engine as any)._lastContext.journal; + const plainFailSpy = jest.spyOn(journal, 'failGeneratedAttempt'); + const history = (engine as any)._lastRunner.getState().historyLog as any[]; + let observerThrows = 0; + let terminalVisibleAtThrow = false; + Object.defineProperty(history, 'push', { + configurable: true, + value: function (...items: any[]) { + if (observerThrows === 0 && items.some(item => item.type === 'CheckErrored' && item.checkId === 'inspect')) { + observerThrows++; + terminalVisibleAtThrow = attemptEvents(engine, control.request.binding.attemptId).some( + event => event.type === 'ManagedRunTerminated' + ); + throw new Error('TEST_OUTER_OBSERVER_FAILURE'); + } + return Array.prototype.push.apply(this, items); + }, + }); + + control.started.resolve(startedReceipt(control.request.binding)); + control.outcome.reject(new Error('RAW_OUTCOME_SECRET')); + await until(() => control.closeCalls === 1, 'outer observer clean close'); + control.close.resolve(cleanupReceipt(control.request.binding)); + await run; + + const events = attemptEvents(engine, control.request.binding.attemptId); + expect(observerThrows).toBe(1); + expect(terminalVisibleAtThrow).toBe(true); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_OUTCOME_FAILED', + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(plainFailSpy).not.toHaveBeenCalled(); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + }); + + it.each([ + { name: 'started rejection', failureCode: 'MANAGED_STARTED_RECEIPT_INVALID' }, + { name: 'outcome rejection', failureCode: 'MANAGED_OUTCOME_FAILED' }, + ])('closes exactly once after $name and publishes no success', async row => { + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(), + 'table', + false, + 1 + ); + await until(() => controls.length === 1, `${row.name} acquisition`); + const control = controls[0]; + if (row.name === 'started rejection') { + control.started.reject(new Error('RAW_STARTED_SECRET')); + } else { + control.started.resolve(startedReceipt(control.binding)); + await until( + () => + attemptEvents(engine, control.binding.attemptId).some( + event => event.type === 'ManagedRunStarted' + ), + 'started fact before outcome rejection' + ); + control.outcome.reject(new Error('RAW_OUTCOME_SECRET')); + } + await until(() => control.closeCalls === 1, `${row.name} close`); + control.close.resolve(cleanupReceipt(control.binding)); + await run; + + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.map(event => event.type)).toEqual( + row.name === 'started rejection' + ? [ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunTerminated', + 'AttemptFailed', + ] + : [ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunStarted', + 'ManagedRunTerminated', + 'AttemptFailed', + ] + ); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: row.failureCode, + }); + expect(events.find(event => event.type === 'AttemptFailed')).toMatchObject({ + reason: row.failureCode, + }); + expect(events.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(events.some(event => event.type === 'AttemptCompleted')).toBe(false); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + expect(JSON.stringify(events)).not.toContain('RAW_STARTED_SECRET'); + expect(JSON.stringify(events)).not.toContain('RAW_OUTCOME_SECRET'); + }); + + it.each([ + { name: 'started extra field', failureCode: 'MANAGED_STARTED_RECEIPT_INVALID' }, + { name: 'started missing binding', failureCode: 'MANAGED_STARTED_RECEIPT_INVALID' }, + { name: 'outcome binding mismatch', failureCode: 'MANAGED_OUTCOME_RECEIPT_INVALID' }, + { name: 'outcome extra field', failureCode: 'MANAGED_OUTCOME_RECEIPT_INVALID' }, + { name: 'outcome missing summary', failureCode: 'MANAGED_OUTCOME_RECEIPT_INVALID' }, + { name: 'close rejection', failureCode: 'MANAGED_CLOSE_FAILED' }, + { name: 'invalid cleanup', failureCode: 'MANAGED_CLEANUP_RECEIPT_INVALID' }, + { name: 'cleanup missing activeChildren', failureCode: 'MANAGED_CLEANUP_RECEIPT_INVALID' }, + ])('fails closed on $name without serializing hostile receipt data', async row => { + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(), + 'table', + false, + 1 + ); + await until(() => controls.length === 1, `${row.name} acquisition`); + const control = controls[0]; + + if (row.name.startsWith('started')) { + control.started.resolve( + row.name === 'started missing binding' + ? ({ version: 1, kind: 'started' } as unknown as ManagedRunStartedReceiptV1) + : ({ + ...startedReceipt(control.binding), + rawSecret: 'RAW_RECEIPT_SECRET', + } as unknown as ManagedRunStartedReceiptV1) + ); + } else { + control.started.resolve(startedReceipt(control.binding)); + if (row.name === 'outcome binding mismatch') { + control.outcome.resolve({ + ...successOutcome(control.binding, { + issues: [], + output: { id: 'A', findings: ['hostile'] }, + }), + binding: { ...control.binding, fence: control.binding.fence + 1 }, + }); + } else if (row.name === 'outcome extra field') { + control.outcome.resolve({ + ...successOutcome(control.binding, { + issues: [], + output: { id: 'A', findings: ['hostile'] }, + }), + rawSecret: 'RAW_RECEIPT_SECRET', + } as unknown as ManagedRunOutcomeV1); + } else if (row.name === 'outcome missing summary') { + control.outcome.resolve({ + version: 1, + kind: 'succeeded', + binding: control.binding, + } as unknown as ManagedRunOutcomeV1); + } else { + control.outcome.resolve( + successOutcome(control.binding, { + issues: [], + output: { id: 'A', findings: ['hostile'] }, + }) + ); + } + } + + await until(() => control.closeCalls === 1, `${row.name} close`); + if (row.name === 'close rejection') { + control.close.reject(new Error('RAW_CLOSE_SECRET')); + } else if (row.name === 'invalid cleanup') { + control.close.resolve({ + ...cleanupReceipt(control.binding), + activeResources: 1, + rawSecret: 'RAW_RECEIPT_SECRET', + } as unknown as ManagedRunCleanupReceiptV1); + } else if (row.name === 'cleanup missing activeChildren') { + const { activeChildren: _omitted, ...missingActiveChildren } = cleanupReceipt( + control.binding + ); + control.close.resolve(missingActiveChildren as unknown as ManagedRunCleanupReceiptV1); + } else { + control.close.resolve(cleanupReceipt(control.binding)); + } + await run; + + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: + row.failureCode === 'MANAGED_CLOSE_FAILED' || + row.failureCode === 'MANAGED_CLEANUP_RECEIPT_INVALID' + ? 'unverified' + : 'clean', + controllerDecision: 'failed', + failureCode: row.failureCode, + }); + expect(events.some(event => event.type === 'ClaimPublished')).toBe(false); + expect(events.some(event => event.type === 'AttemptCompleted')).toBe(false); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + expect(JSON.stringify(events)).not.toContain('RAW_RECEIPT_SECRET'); + expect(JSON.stringify(events)).not.toContain('RAW_CLOSE_SECRET'); + }); + + it.each([ + { name: 'cancel rejection', failureCode: 'MANAGED_CANCEL_FAILED' }, + { name: 'invalid cancel receipt', failureCode: 'MANAGED_CANCEL_RECEIPT_INVALID' }, + { name: 'cancel missing reason', failureCode: 'MANAGED_CANCEL_RECEIPT_INVALID' }, + ])('lets $name settle while close runs independently', async row => { + jest.useFakeTimers(); + const config = fixtureConfig(); + setManagedTimeout(config, 25); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'cancel rejection acquisition'); + const control = controls[0]; + + jest.advanceTimersByTime(25); + await until(() => control.cancelCalls.length === 1, 'cancel rejection deadline'); + expect(control.closeCalls).toBe(1); + if (row.name === 'cancel rejection') { + control.cancel.reject(new Error('RAW_CANCEL_SECRET')); + } else if (row.name === 'invalid cancel receipt') { + control.cancel.resolve({ + ...cancelReceipt(control.binding), + rawSecret: 'RAW_CANCEL_SECRET', + } as unknown as ManagedRunCancelReceiptV1); + } else { + control.cancel.resolve({ + version: 1, + kind: 'cancelled', + binding: control.binding, + } as unknown as ManagedRunCancelReceiptV1); + } + await Promise.resolve(); + expect( + attemptEvents(engine, control.binding.attemptId).some( + event => event.type === 'ManagedRunTerminated' + ) + ).toBe(false); + + control.close.resolve(cleanupReceipt(control.binding)); + await run; + const events = attemptEvents(engine, control.binding.attemptId); + expect(events.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: row.failureCode, + }); + expect(events.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(events.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(originalCancelCalls).toBe(1); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + expect(JSON.stringify(events)).not.toContain('RAW_CANCEL_SECRET'); + }); + + it('closes without unauthoritative cancel when the cancel fact callback throws and holds both competitors', async () => { + jest.useFakeTimers(); + catalogs = [ + [ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ], + [ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ], + ]; + const config = fixtureConfig(); + setManagedTimeout(config, 25); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'cancel callback acquisition'); + const first = controls[0]; + const firstKey = keyOf(first.request); + const reconciliation = engine.requestCatalogReconciliation('discover-components'); + const journal = (engine as any)._lastContext.journal; + jest.spyOn(journal, 'recordManagedRunCancelRequested').mockImplementationOnce(() => { + throw new Error('TEST_CANCEL_FACT_FAILURE'); + }); + + jest.advanceTimersByTime(25); + await until(() => first.closeCalls === 1, 'same-turn close after cancel fact failure'); + expect(first.cancelCalls).toHaveLength(0); + expect(originalCancelCalls).toBe(0); + expect(controls).toHaveLength(1); + expect(catalogCalls).toBe(1); + expect(activeHandles).toBe(1); + expect( + attemptEvents(engine, first.request.binding.attemptId).some( + event => event.type === 'ManagedRunTerminated' + ) + ).toBe(false); + + first.close.resolve(cleanupReceipt(first.request.binding)); + await until(() => controls.length === 2, 'generated competitor after terminal'); + const firstEvents = attemptEvents(engine, first.request.binding.attemptId); + expect(firstEvents.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ManagedRunAcquired', + 'ManagedRunTerminated', + 'AttemptFailed', + ]); + expect(firstEvents.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_POST_PROVIDER_FAILED', + }); + expect(firstEvents.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(firstEvents.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(firstEvents.some(event => event.type === 'ManagedRunCancelRequested')).toBe(false); + expect(catalogCalls).toBe(1); + + const second = controls[1]; + const secondKey = keyOf(second.request); + expect([firstKey, secondKey].sort()).toEqual(['A', 'B']); + second.started.resolve(startedReceipt(second.request.binding)); + second.outcome.resolve( + successOutcome(second.request.binding, { + issues: [], + output: { id: secondKey, findings: ['competitor'] }, + }) + ); + await until(() => second.closeCalls === 1, 'generated competitor close'); + second.close.resolve(cleanupReceipt(second.request.binding)); + await until(() => catalogCalls === 2, 'catalog competitor after generated terminal'); + await run; + + expect(launchOrder).toEqual([ + 'catalog:0', + `managed:${firstKey}`, + `managed:${secondKey}`, + 'catalog:1', + ]); + expect( + journal.getInstanceProjection().requestsById[reconciliation.requestId].status + ).toBe('completed'); + expect(originalCloseCalls).toBe(2); + expect(activeHandles).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('holds generated and catalog competitors across an ordinary finite deadline', async () => { + jest.useFakeTimers(); + catalogs = [ + [ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ], + [ + { id: 'A', path: 'packages/a' }, + { id: 'B', path: 'packages/b' }, + ], + ]; + const config = fixtureConfig(); + setManagedTimeout(config, 25); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'ordinary finite deadline acquisition'); + const first = controls[0]; + const firstKey = keyOf(first.request); + engine.requestCatalogReconciliation('discover-components'); + + jest.advanceTimersByTime(25); + await until( + () => first.cancelCalls.length === 1 && first.closeCalls === 1, + 'ordinary finite cancel and close' + ); + expect(controls).toHaveLength(1); + expect(catalogCalls).toBe(1); + expect(activeHandles).toBe(1); + + first.cancel.resolve(cancelReceipt(first.request.binding)); + await Promise.resolve(); + expect(controls).toHaveLength(1); + expect(catalogCalls).toBe(1); + expect( + attemptEvents(engine, first.request.binding.attemptId).some( + event => event.type === 'ManagedRunTerminated' + ) + ).toBe(false); + + first.close.resolve(cleanupReceipt(first.request.binding)); + await until(() => controls.length === 2, 'generated competitor after finite terminal'); + const firstEvents = attemptEvents(engine, first.request.binding.attemptId); + expect(firstEvents.find(event => event.type === 'ManagedRunTerminated')).toMatchObject({ + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + expect(firstEvents.filter(event => event.type === 'ManagedRunTerminated')).toHaveLength(1); + expect(firstEvents.filter(event => event.type === 'AttemptFailed')).toHaveLength(1); + expect(catalogCalls).toBe(1); + + const second = controls[1]; + const secondKey = keyOf(second.request); + expect([firstKey, secondKey].sort()).toEqual(['A', 'B']); + second.started.resolve(startedReceipt(second.request.binding)); + second.outcome.resolve( + successOutcome(second.request.binding, { + issues: [], + output: { id: secondKey, findings: ['after finite deadline'] }, + }) + ); + await until(() => second.closeCalls === 1, 'finite competitor close'); + second.close.resolve(cleanupReceipt(second.request.binding)); + await until(() => catalogCalls === 2, 'catalog after finite competitor'); + await run; + + expect(launchOrder).toEqual([ + 'catalog:0', + `managed:${firstKey}`, + `managed:${secondKey}`, + 'catalog:1', + ]); + expect(originalCancelCalls).toBe(1); + expect(originalCloseCalls).toBe(2); + expect(activeHandles).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('commits downstream generation activation inside the managed success batch', async () => { + const config = fixtureConfig(); + (config.subgraphs!['onboard-component'].checks as any).verify = { + type: LEGACY_PROVIDER, + depends_on: ['inspect'], + consumes: [{ claim: 'component.onboarded@1', as: 'onboarded' }], + }; + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'downstream activation acquisition'); + const control = controls[0]; + control.started.resolve(startedReceipt(control.request.binding)); + control.outcome.resolve( + successOutcome(control.request.binding, { + issues: [], + output: { id: 'A', findings: ['activates downstream'] }, + }) + ); + await until(() => control.closeCalls === 1, 'downstream activation close'); + + const beforeClose = attemptEvents(engine, control.request.binding.attemptId); + expect(beforeClose.some(event => event.type === 'ManagedRunTerminated')).toBe(false); + expect(beforeClose.some(event => event.type === 'ClaimPublished')).toBe(false); + const allBeforeClose = (engine as any)._lastContext.journal.readRuntimeEvents() as readonly any[]; + expect(allBeforeClose.some(event => event.type === 'NodeGenerationActivated' && event.checkId === 'verify')).toBe(false); + expect(beforeClose.some(event => event.type === 'AttemptCompleted')).toBe(false); + + control.close.resolve(cleanupReceipt(control.request.binding)); + await run; + const allEvents = (engine as any)._lastContext.journal.readRuntimeEvents() as readonly any[]; + const terminalIndex = allEvents.findIndex( + event => + event.type === 'ManagedRunTerminated' && + event.binding?.attemptId === control.request.binding.attemptId + ); + const completedIndex = allEvents.findIndex( + event => + event.type === 'AttemptCompleted' && + event.attemptId === control.request.binding.attemptId + ); + expect(allEvents.slice(terminalIndex, completedIndex + 1).map(event => event.type)).toEqual([ + 'ManagedRunTerminated', + 'ClaimPublished', + 'NodeGenerationActivated', + 'AttemptCompleted', + ]); + expect(launchOrder).toEqual(['catalog:0', 'managed:A', 'legacy:verify']); + expect(originalCloseCalls).toBe(1); + expect(activeHandles).toBe(0); + }); + + it('orders journal authority before provider, telemetry, events, transitions, and callbacks', async () => { + jest.useFakeTimers(); + observationLane = []; + const lane = observationLane; + jest.mocked(traceHelpers.emitImmediateSpan).mockImplementation((name: string) => { + lane.push(`telemetry:${name}`); + }); + jest.mocked(ndjsonTelemetry.emitNdjsonFallback).mockImplementation((name: string) => { + lane.push(`telemetry:${name}`); + }); + const originalBuild = (engine as any).buildEngineContext.bind(engine); + jest.spyOn(engine as any, 'buildEngineContext').mockImplementation((...args: unknown[]) => { + const built = originalBuild(...args); + const eventBus = new EventBus(); + eventBus.onAny(event => { + const observed = event as any; + lane.push(`event:${observed.payload?.type || observed.type}`); + }); + built.eventBus = eventBus; + const journal = built.journal as Record any>; + const wrap = (method: string, label: string) => { + const original = journal[method].bind(journal); + journal[method] = (...callArgs: any[]) => { + const value = original(...callArgs); + lane.push(`journal:${label}`); + return value; + }; + }; + wrap('scheduleGeneratedAttempt', 'scheduled'); + wrap('recordManagedRunAcquired', 'acquired'); + wrap('recordManagedRunStarted', 'started'); + wrap('recordManagedRunCancelRequested', 'cancel-requested'); + wrap('failManagedGeneratedAttempt', 'terminal-failed'); + wrap('completeManagedGeneratedAttempt', 'terminal-completed'); + return built; + }); + const indexAfter = (label: string, after = -1) => + lane.findIndex((value, index) => index > after && value === label); + + const deadlineConfig = fixtureConfig(); + setManagedTimeout(deadlineConfig, 25); + const deadlineRun = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + deadlineConfig, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'global order deadline acquisition'); + const deadlineControl = controls[0]; + deadlineControl.started.resolve(startedReceipt(deadlineControl.request.binding)); + await until(() => lane.includes('journal:started'), 'global order started fact'); + jest.advanceTimersByTime(25); + await until( + () => deadlineControl.cancelCalls.length === 1 && deadlineControl.closeCalls === 1, + 'global order deadline cleanup calls' + ); + deadlineControl.cancel.resolve(cancelReceipt(deadlineControl.request.binding)); + deadlineControl.close.resolve(cleanupReceipt(deadlineControl.request.binding)); + await deadlineRun; + + const scheduled = indexAfter('journal:scheduled'); + const checkScheduled = indexAfter('event:CheckScheduled', scheduled); + const providerStart = indexAfter('provider:start:A', checkScheduled); + const acquired = indexAfter('journal:acquired', providerStart); + const acquiredObservation = indexAfter('telemetry:visor.provider', acquired); + const started = indexAfter('journal:started', acquired); + const startedObservation = indexAfter('telemetry:visor.check.inspect.started', started); + const cancelRequested = indexAfter('journal:cancel-requested', started); + const providerCancel = indexAfter('provider:cancel:A', cancelRequested); + const providerClose = indexAfter('provider:close:A', cancelRequested); + const deadlineTerminal = indexAfter('journal:terminal-failed', providerClose); + const failureTelemetry = indexAfter('telemetry:visor.check.inspect.failed', deadlineTerminal); + const checkErrored = indexAfter('event:CheckErrored', deadlineTerminal); + expect([ + scheduled, + checkScheduled, + providerStart, + acquired, + acquiredObservation, + started, + startedObservation, + cancelRequested, + providerCancel, + providerClose, + deadlineTerminal, + failureTelemetry, + checkErrored, + ].every(index => index >= 0)).toBe(true); + expect(scheduled).toBeLessThan(checkScheduled); + expect(checkScheduled).toBeLessThan(providerStart); + expect(providerStart).toBeLessThan(acquired); + expect(acquired).toBeLessThan(acquiredObservation); + expect(started).toBeLessThan(startedObservation); + expect(cancelRequested).toBeLessThan(providerCancel); + expect(cancelRequested).toBeLessThan(providerClose); + expect(deadlineTerminal).toBeLessThan(failureTelemetry); + expect(deadlineTerminal).toBeLessThan(checkErrored); + + const haltStart = lane.length; + controls = []; + activeHandles = 0; + catalogCalls = 0; + launchOrder = []; + const haltConfig = fixtureConfig(); + (haltConfig.subgraphs!['onboard-component'].checks.inspect as any).failure_conditions = { + fixture: { + condition: 'true', + message: 'ordered halt', + severity: 'error', + halt_execution: true, + }, + }; + const haltRun = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + haltConfig, + 'table', + false, + 1 + ); + await until(() => controls.length === 1, 'global order halt acquisition'); + const haltControl = controls[0]; + haltControl.started.resolve(startedReceipt(haltControl.request.binding)); + haltControl.outcome.resolve( + successOutcome(haltControl.request.binding, { + issues: [], + output: { id: 'A', findings: ['halt'] }, + }) + ); + await until(() => haltControl.closeCalls === 1, 'global order halt close'); + haltControl.close.resolve(cleanupReceipt(haltControl.request.binding)); + await haltRun; + + const haltTerminal = indexAfter('journal:terminal-failed', haltStart - 1); + const shutdown = indexAfter('event:Shutdown', haltTerminal); + const transition = indexAfter('event:StateTransition', haltTerminal); + const haltTelemetry = indexAfter('telemetry:visor.check.inspect.failed', haltTerminal); + const completed = indexAfter('event:CheckCompleted', haltTerminal); + const callback = indexAfter('callback:inspect', haltTerminal); + expect([haltTerminal, shutdown, transition, haltTelemetry, completed, callback].every( + index => index >= 0 + )).toBe(true); + expect(haltTerminal).toBeLessThan(shutdown); + expect(haltTerminal).toBeLessThan(transition); + expect(haltTerminal).toBeLessThan(haltTelemetry); + expect(haltTerminal).toBeLessThan(completed); + expect(haltTerminal).toBeLessThan(callback); + expect(activeHandles).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('leaves execute-only providers on the legacy interval and terminal path', async () => { + observationLane = []; + const lane = observationLane; + const nativeSetInterval = global.setInterval.bind(global); + const nativeClearInterval = global.clearInterval.bind(global); + const intervalSpy = jest.spyOn(global, 'setInterval').mockImplementation(( + (handler: (...args: any[]) => void, timeout?: number, ...args: any[]) => { + lane.push('interval:start'); + return nativeSetInterval(handler, timeout, ...args); + } + ) as typeof setInterval); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval').mockImplementation(( + (timer: ReturnType) => { + lane.push('interval:stop'); + return nativeClearInterval(timer); + } + ) as typeof clearInterval); + intervalCallCount = () => intervalSpy.mock.calls.length; + jest.mocked(traceHelpers.emitImmediateSpan).mockImplementation((name: string) => { + lane.push(`telemetry:${name}`); + }); + jest.mocked(ndjsonTelemetry.emitNdjsonFallback).mockImplementation((name: string) => { + lane.push(`telemetry:${name}`); + }); + const originalBuild = (engine as any).buildEngineContext.bind(engine); + jest.spyOn(engine as any, 'buildEngineContext').mockImplementation((...args: unknown[]) => { + const built = originalBuild(...args); + const eventBus = new EventBus(); + eventBus.onAny(event => { + const observed = event as any; + lane.push(`event:${observed.payload?.type || observed.type}`); + }); + built.eventBus = eventBus; + const journal = built.journal; + const schedule = journal.scheduleGeneratedAttempt.bind(journal); + journal.scheduleGeneratedAttempt = (...callArgs: any[]) => { + const value = schedule(...callArgs); + lane.push('journal:scheduled'); + return value; + }; + const complete = journal.completeGeneratedAttempt.bind(journal); + journal.completeGeneratedAttempt = (...callArgs: any[]) => { + const value = complete(...callArgs); + lane.push('journal:attempt-completed'); + return value; + }; + return built; + }); + const result = await engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + fixtureConfig(LEGACY_PROVIDER), + 'table', + false, + 1 + ); + + const events = (engine as any)._lastContext.journal.readRuntimeEvents() as readonly any[]; + const generated = events.filter(event => event.checkId === 'inspect'); + expect(launchOrder).toEqual(['legacy:discover-components', 'legacy:inspect']); + expect(startManagedCalls).toBe(0); + expect(events.some(event => String(event.type).startsWith('ManagedRun'))).toBe(false); + expect(generated.map(event => event.type)).toEqual([ + 'NodeGenerationActivated', + 'AttemptStarted', + 'CheckScheduled', + 'ClaimPublished', + 'AttemptCompleted', + ]); + expect(generated.find(event => event.type === 'ClaimPublished').payload).toEqual({ + id: 'A', + findings: ['legacy'], + }); + const indexAfter = (label: string, after = -1) => + lane.findIndex((value, index) => index > after && value === label); + const scheduled = indexAfter('journal:scheduled'); + const checkScheduled = indexAfter('event:CheckScheduled', scheduled); + const providerTelemetry = indexAfter('telemetry:visor.provider', checkScheduled); + const startedTelemetry = indexAfter('telemetry:visor.check.inspect.started', providerTelemetry); + const intervalStarted = indexAfter('interval:start', startedTelemetry); + const providerExecution = indexAfter('provider:legacy:inspect', intervalStarted); + const completedTelemetry = indexAfter( + 'telemetry:visor.check.inspect.completed', + providerExecution + ); + const intervalStopped = indexAfter('interval:stop', completedTelemetry); + const terminal = indexAfter('journal:attempt-completed', intervalStopped); + const checkCompleted = indexAfter('event:CheckCompleted', terminal); + const callback = indexAfter('callback:inspect', checkCompleted); + expect([ + scheduled, + checkScheduled, + providerTelemetry, + startedTelemetry, + intervalStarted, + providerExecution, + intervalStopped, + terminal, + completedTelemetry, + checkCompleted, + callback, + ].every(index => index >= 0)).toBe(true); + expect(scheduled).toBeLessThan(checkScheduled); + expect(checkScheduled).toBeLessThan(providerTelemetry); + expect(providerTelemetry).toBeLessThan(startedTelemetry); + expect(startedTelemetry).toBeLessThan(intervalStarted); + expect(intervalStarted).toBeLessThan(providerExecution); + expect(providerExecution).toBeLessThan(completedTelemetry); + expect(completedTelemetry).toBeLessThan(intervalStopped); + expect(intervalStopped).toBeLessThan(terminal); + expect(terminal).toBeLessThan(checkCompleted); + expect(completedTelemetry).toBeLessThan(checkCompleted); + expect(checkCompleted).toBeLessThan(callback); + expect(lane.some(name => name === 'telemetry:visor.check.inspect.progress')).toBe(false); + expect(intervalSpy).toHaveBeenCalledTimes(2); + expect(clearIntervalSpy).toHaveBeenCalledTimes(2); + expect(legacyIntervalCallsAtInspectStart).toBe(2); + const observed = ((engine as any)._lastRunner.getState().historyLog as readonly any[]).filter( + event => event.checkId === 'inspect' && + (event.type === 'CheckScheduled' || event.type === 'CheckCompleted') + ); + expect(observed.map(event => event.type)).toEqual(['CheckScheduled', 'CheckCompleted']); + expect(JSON.stringify(observed[1].result)).toBe(JSON.stringify(legacyInspectResult)); + expect(result.statistics.failedExecutions).toBe(0); + }); +}); + +const EXP_0205_MANAGED_PROVIDER = 'exp-0205-managed'; +const EXP_0205_VERIFY_PROVIDER = 'exp-0205-verify'; + +describe('EXP-0205 explicit proof admission node', () => { + const registry = CheckProviderRegistry.getInstance(); + let engine: StateMachineExecutionEngine; + let managedRequests: ManagedRunStartRequest[]; + let verifyClaims: Array>>; + + class ExplicitManagedProvider extends CheckProvider { + getName() { return EXP_0205_MANAGED_PROVIDER; } + getDescription() { return 'EXP-0205 managed fixture'; } + async validateConfig() { return true; } + async isAvailable() { return true; } + getRequirements() { return []; } + getSupportedConfigKeys() { return ['type']; } + async execute(_pr: PRInfo, config: CheckProviderConfig): Promise { + if (config.checkName !== 'discover-components') throw new Error('EXP_0205_UNEXPECTED_EXECUTE'); + return { issues: [], output: { components: [{ id: 'A', path: 'a' }, { id: 'B', path: 'b' }] } }; + } + startManaged(request: ManagedRunStartRequest): ManagedAgentRun { + managedRequests.push(request); + const key = String(request.binding.scope[0]?.key); + const summary: ReviewSummary = { issues: [], output: { id: key === 'A' ? 'B' : 'A', decision: key === 'A' ? 'reject' : 'accept', admit: key === 'B', item: key === 'B' ? 'A' : 'B' } }; + return { + binding: request.binding, + started: Promise.resolve(startedReceipt(request.binding)), + outcome: Promise.resolve(successOutcome(request.binding, summary)), + cancel: async _reason => cancelReceipt(request.binding), + close: async () => cleanupReceipt(request.binding), + }; + } + } + + class ExplicitVerifyProvider extends CheckProvider { + getName() { return EXP_0205_VERIFY_PROVIDER; } + getDescription() { return 'EXP-0205 verify fixture'; } + async validateConfig() { return true; } + async isAvailable() { return true; } + getRequirements() { return []; } + getSupportedConfigKeys() { return ['type']; } + async execute(_pr: PRInfo, _config: CheckProviderConfig, _deps?: Map, context?: ExecutionContext): Promise { + verifyClaims.push(context?.claims || {}); + return { issues: [], output: { verified: true } }; + } + } + + beforeEach(() => { + engine = new StateMachineExecutionEngine(); + managedRequests = []; + verifyClaims = []; + registry.register(new ExplicitManagedProvider()); + registry.register(new ExplicitVerifyProvider()); + }); + + afterEach(() => { + registry.unregister(EXP_0205_MANAGED_PROVIDER); + registry.unregister(EXP_0205_VERIFY_PROVIDER); + jest.restoreAllMocks(); + }); + + it('accepts A, rejects B, preserves lineage, and replays the live projection', async () => { + const config: any = fixtureConfig(EXP_0205_MANAGED_PROVIDER); + config.max_parallelism = 2; + Object.assign(config.claim_types, { + 'proof.candidate@1': { schema: { type: 'object', required: ['id', 'decision'], properties: { id: { type: 'string' }, decision: { type: 'string' } } } }, + 'proof.admitted_receipt@1': { schema: { type: 'object' } }, + }); + config.subgraphs['onboard-component'].checks = { + inspect: { type: EXP_0205_MANAGED_PROVIDER, consumes: [{ claim: 'component.item@1', as: 'component' }], emits: [{ claim: 'proof.candidate@1', from: 'output' }] }, + proof_admit: { type: 'proof-admit', consumes: [{ claim: 'proof.candidate@1', as: 'candidate' }], emits: [{ claim: 'proof.admitted_receipt@1', from: 'output' }] }, + verify: { type: EXP_0205_VERIFY_PROVIDER, consumes: [{ claim: 'proof.candidate@1', as: 'candidate' }, { claim: 'proof.admitted_receipt@1', as: 'receipt' }] }, + }; + const result = await engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 2 + ); + const journal = (engine as any)._lastContext.journal; + const events = journal.readRuntimeEvents() as readonly any[]; + const generated = (key: string) => events.filter(event => event.scope?.[0]?.key === key); + const accepted = generated('A'); + const rejected = generated('B'); + const candidateA = accepted.find(event => event.type === 'ClaimPublished' && event.claim === 'proof.candidate@1'); + const receiptA = accepted.find(event => event.type === 'ClaimPublished' && event.claim === 'proof.admitted_receipt@1'); + const candidateB = rejected.find(event => event.type === 'ClaimPublished' && event.claim === 'proof.candidate@1'); + const ledger = (key: string) => { const scoped = generated(key); const start = scoped.findIndex(event => event.type === 'ManagedRunTerminated'); return scoped.slice(start).map(event => `${event.type}:${event.checkId || ''}:${event.claim || ''}:${event.reason || ''}`); }; + for (const key of ['A', 'B']) { const terminated = generated(key).filter(event => event.type === 'ManagedRunTerminated'); expect(terminated).toHaveLength(1); expect(terminated[0]).toMatchObject({ controllerDecision: 'completed', cleanupStatus: 'clean' }); } + + expect(result.statistics.failedExecutions).toBe(1); + expect(managedRequests).toHaveLength(2); + expect([candidateA, receiptA, candidateB]).toEqual([expect.anything(), expect.anything(), expect.anything()]); + expect(receiptA.parentClaimIds).toEqual([candidateA.claimId]); + expect(receiptA.payload.candidateClaimId).toBe(candidateA.claimId); + expect(receiptA.payload.parentClaimIds).toEqual(candidateA.parentClaimIds); + expect([...accepted.find(event => event.type === 'NodeGenerationActivated' && event.checkId === 'verify').activeInputClaimIds].sort()).toEqual([candidateA.claimId, receiptA.claimId].sort()); + expect(verifyClaims).toHaveLength(1); + expect(Object.values(verifyClaims[0]).map(claim => claim.claimId).sort()).toEqual( + [candidateA.claimId, receiptA.claimId].sort() + ); + expect(rejected.some(event => event.type === 'ClaimPublished' && event.claim === 'proof.admitted_receipt@1')).toBe(false); + expect(rejected.some(event => event.type === 'NodeGenerationActivated' && event.checkId === 'verify')).toBe(false); + expect(rejected.some(event => event.type === 'AttemptFailed' && event.reason === 'PROVIDER_EXECUTION_FAILED')).toBe(true); + expect(ledger('A')).toEqual(['ManagedRunTerminated:::', 'ClaimPublished:inspect:proof.candidate@1:', 'NodeGenerationActivated:proof_admit::', 'AttemptCompleted:inspect::', 'AttemptStarted:proof_admit::', 'CheckScheduled:proof_admit::', 'ClaimPublished:proof_admit:proof.admitted_receipt@1:', 'NodeGenerationActivated:verify::', 'AttemptCompleted:proof_admit::', 'AttemptStarted:verify::', 'CheckScheduled:verify::', 'AttemptCompleted:verify::']); + expect(ledger('B')).toEqual(['ManagedRunTerminated:::', 'ClaimPublished:inspect:proof.candidate@1:', 'NodeGenerationActivated:proof_admit::', 'AttemptCompleted:inspect::', 'AttemptStarted:proof_admit::', 'CheckScheduled:proof_admit::', 'AttemptFailed:proof_admit::PROVIDER_EXECUTION_FAILED']); + expect(journal.getInstanceProjection()).toEqual(journal.replayInstanceProjection()); + }); +}); diff --git a/tests/engine/native-typed-claim-kernel.exp-0121.engine.test.ts b/tests/engine/native-typed-claim-kernel.exp-0121.engine.test.ts new file mode 100644 index 000000000..2399719a8 --- /dev/null +++ b/tests/engine/native-typed-claim-kernel.exp-0121.engine.test.ts @@ -0,0 +1,539 @@ +import { StateMachineExecutionEngine } from '../../src/state-machine-execution-engine'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { CheckProvider, CheckProviderConfig, ExecutionContext } from '../../src/providers/check-provider.interface'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { ReviewSummary } from '../../src/reviewer'; +import type { VisorConfig } from '../../src/types/config'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const prInfo: PRInfo = { + number: 1, + title: 'Graph v2 C1', + author: 'test', + base: 'main', + head: 'candidate', + files: [], + totalAdditions: 0, + totalDeletions: 0, + eventType: 'manual', +} as PRInfo; + +function claimConfig(): VisorConfig { + return { + version: '1.0', + max_parallelism: 2, + workspace: { enabled: false }, + claim_types: { + 'fixture.ready@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['value'], + properties: { value: { type: 'string', const: 'ready' } }, + }, + }, + }, + checks: { + producer: { + type: 'noop', + emits: [{ claim: 'fixture.ready@1', from: 'output' }], + }, + 'slow-sibling': { type: 'noop' }, + consumer: { + type: 'noop', + consumes: [{ claim: 'fixture.ready@1', cardinality: 'one' }], + }, + }, + }; +} + +describe('EXP-0121 native typed-claim engine', () => { + const registry = CheckProviderRegistry.getInstance(); + const originalNoop = registry.getProviderOrThrow('noop'); + let engine: StateMachineExecutionEngine; + let producerOutput: unknown; + let slowGate: ReturnType; + let slowStarted: ReturnType; + let consumerStarted: ReturnType; + let invocations: string[]; + let consumerClaims: ExecutionContext['claims']; + let active: number; + let peakActive: number; + let scheduleVisibleAtProviderStart: Record; + let historyVisibleAtProviderStart: Record; + + class ControlledNoopProvider extends CheckProvider { + getName() { + return 'noop'; + } + getDescription() { + return 'EXP-0121 deterministic fake'; + } + async validateConfig() { + return true; + } + async isAvailable() { + return true; + } + getRequirements() { + return []; + } + getSupportedConfigKeys() { + return ['type', 'emits', 'consumes']; + } + async execute( + _pr: PRInfo, + config: CheckProviderConfig, + _dependencies?: Map, + context?: ExecutionContext + ): Promise { + const checkId = String(config.checkName); + invocations.push(checkId); + active++; + peakActive = Math.max(peakActive, active); + try { + const runContext = (engine as any)._lastContext; + scheduleVisibleAtProviderStart[checkId] = runContext.journal + .readRuntimeEvents() + .some((event: any) => event.type === 'CheckScheduled' && event.checkId === checkId); + historyVisibleAtProviderStart[checkId] = (engine as any)._lastRunner + .getState() + .historyLog.some( + (event: any) => event.type === 'CheckScheduled' && event.checkId === checkId + ); + + if (checkId === 'producer') return { issues: [], output: producerOutput }; + if (checkId === 'undefined-foreach') return { issues: [], output: undefined }; + if (checkId === 'slow-sibling') { + slowStarted.resolve(); + await slowGate.promise; + return { issues: [], output: { unrelated: 'must-not-leak' } }; + } + if (checkId === 'consumer') { + consumerClaims = context?.claims; + consumerStarted.resolve(); + return { issues: [], output: { consumed: true } }; + } + return { issues: [], output: { checkId } }; + } finally { + active--; + } + } + } + + beforeEach(() => { + engine = new StateMachineExecutionEngine(); + producerOutput = { value: 'ready' }; + slowGate = deferred(); + slowStarted = deferred(); + consumerStarted = deferred(); + invocations = []; + consumerClaims = undefined; + active = 0; + peakActive = 0; + scheduleVisibleAtProviderStart = {}; + historyVisibleAtProviderStart = {}; + registry.unregister('noop'); + registry.register(new ControlledNoopProvider()); + }); + + afterEach(() => { + registry.unregister('noop'); + registry.register(originalNoop); + }); + + it('journals before release, overlaps ready work, and grants exact isolated context', async () => { + const run = engine.executeGroupedChecks( + prInfo, + ['producer', 'slow-sibling', 'consumer'], + undefined, + claimConfig(), + 'table', + false, + 2 + ); + + await Promise.race([ + consumerStarted.promise, + run.then(() => { + throw new Error('engine completed before claim consumer started'); + }), + ]); + await slowStarted.promise; + + expect(invocations).toEqual(expect.arrayContaining(['producer', 'slow-sibling', 'consumer'])); + expect(peakActive).toBe(2); + expect(scheduleVisibleAtProviderStart).toEqual({ + producer: true, + 'slow-sibling': true, + consumer: true, + }); + expect(historyVisibleAtProviderStart).toEqual({ + producer: true, + 'slow-sibling': true, + consumer: true, + }); + expect(Object.keys(consumerClaims || {})).toEqual(['fixture.ready@1']); + expect(consumerClaims?.['fixture.ready@1'].payload).toEqual({ value: 'ready' }); + expect(Object.isFrozen(consumerClaims)).toBe(true); + expect(Object.isFrozen(consumerClaims?.['fixture.ready@1'].payload as object)).toBe(true); + + slowGate.resolve(); + const result = await run; + expect(result.statistics.failedExecutions).toBe(0); + + const journal = (engine as any)._lastContext.journal; + const events = journal.readRuntimeEvents(); + const producerAttempt = events.findIndex( + (event: any) => event.type === 'AttemptStarted' && event.checkId === 'producer' + ); + const published = events.findIndex((event: any) => event.type === 'ClaimPublished'); + const consumerScheduled = events.findIndex( + (event: any) => event.type === 'CheckScheduled' && event.checkId === 'consumer' + ); + expect(producerAttempt).toBeGreaterThanOrEqual(0); + expect(published).toBeGreaterThan(producerAttempt); + expect(consumerScheduled).toBeGreaterThan(published); + expect(events.map((event: any) => event.eventId)).toEqual( + events.map((_: any, index: number) => index + 1) + ); + + const callsBeforeReplay = invocations.length; + expect(journal.replayClaimProjection()).toEqual(journal.getClaimProjection()); + expect(invocations).toHaveLength(callsBeforeReplay); + }); + + it('fails a schema-invalid producer and never starts its consumer', async () => { + producerOutput = { value: 'wrong' }; + const config = claimConfig(); + delete config.checks!['slow-sibling']; + config.checks!.producer.output_schema = {}; + + const result = await engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + + expect(invocations).toEqual(['producer']); + expect(result.statistics.failedExecutions).toBe(1); + const events = (engine as any)._lastContext.journal.readRuntimeEvents(); + expect(events.some((event: any) => event.type === 'ClaimPublished')).toBe(false); + expect( + events.some( + (event: any) => + event.type === 'AttemptFailed' && event.reason === 'CLAIM_SCHEMA_INVALID' + ) + ).toBe(true); + expect( + events.some( + (event: any) => event.type === 'CheckScheduled' && event.checkId === 'consumer' + ) + ).toBe(false); + }); + + it('terminalizes an undefined non-declaring forEach attempt before blocking downstream work', async () => { + const config = claimConfig(); + delete config.checks!['slow-sibling']; + config.checks!['undefined-foreach'] = { type: 'noop', forEach: true }; + config.checks!.producer.depends_on = ['undefined-foreach']; + + await engine.executeGroupedChecks( + prInfo, + ['undefined-foreach', 'producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + + expect(invocations).toEqual(['undefined-foreach']); + const journal = (engine as any)._lastContext.journal; + const events = journal.readRuntimeEvents(); + const undefinedAttempt = events.filter( + (event: any) => event.checkId === 'undefined-foreach' + ); + expect(undefinedAttempt.map((event: any) => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'AttemptFailed', + ]); + expect(undefinedAttempt[2].reason).toBe('UNDEFINED_RESULT'); + expect( + Object.values(journal.getClaimProjection().attempts).some( + (attempt: any) => attempt.status === 'started' + ) + ).toBe(false); + expect(events.some((event: any) => event.type === 'ClaimPublished')).toBe(false); + expect(invocations).not.toContain('consumer'); + }); + + it('atomically publishes two declared claims before releasing their consumer', async () => { + const config = claimConfig(); + delete config.checks!['slow-sibling']; + config.claim_types!['fixture.second@1'] = { + schema: { + type: 'object', + additionalProperties: false, + required: ['value'], + properties: { value: { type: 'string', const: 'ready' } }, + }, + }; + config.checks!.producer.emits!.push({ claim: 'fixture.second@1', from: 'output' }); + config.checks!.consumer.consumes!.push({ + claim: 'fixture.second@1', + cardinality: 'one', + }); + + const result = await engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + + expect(result.statistics.failedExecutions).toBe(0); + expect(invocations).toEqual(['producer', 'consumer']); + expect(Object.keys(consumerClaims || {})).toEqual([ + 'fixture.ready@1', + 'fixture.second@1', + ]); + const events = (engine as any)._lastContext.journal.readRuntimeEvents(); + const producerTerminal = events + .filter((event: any) => event.checkId === 'producer') + .map((event: any) => event.type); + expect(producerTerminal).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ClaimPublished', + 'ClaimPublished', + 'AttemptCompleted', + ]); + }); + + it('publishes no prefix and starts no consumer when a later emission is invalid', async () => { + const config = claimConfig(); + delete config.checks!['slow-sibling']; + config.claim_types!['fixture.second@1'] = { + schema: { + type: 'object', + additionalProperties: false, + required: ['value', 'second'], + properties: { value: { const: 'ready' }, second: { const: true } }, + }, + }; + config.checks!.producer.emits!.push({ claim: 'fixture.second@1', from: 'output' }); + config.checks!.consumer.consumes!.push({ + claim: 'fixture.second@1', + cardinality: 'one', + }); + + const result = await engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + + expect(result.statistics.failedExecutions).toBe(1); + expect(invocations).toEqual(['producer']); + const journal = (engine as any)._lastContext.journal; + expect(journal.readRuntimeEvents().map((event: any) => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'AttemptFailed', + ]); + expect(journal.getClaimProjection().claims).toEqual({}); + }); + + it.each([ + { + name: 'fail_if', + configure: (producer: any) => { + producer.fail_if = 'true'; + }, + reason: 'TERMINAL_RESULT_FAILED', + }, + { + name: 'halt_execution', + configure: (producer: any) => { + producer.failure_conditions = { + stop: { + condition: 'true', + message: 'stop graph', + severity: 'error', + halt_execution: true, + }, + }; + }, + reason: 'HALT_EXECUTION', + }, + ])('publishes zero claims on $name terminal failure', async ({ configure, reason }) => { + const config = claimConfig(); + delete config.checks!['slow-sibling']; + configure(config.checks!.producer); + + const result = await engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + + expect(invocations).toEqual(['producer']); + const events = (engine as any)._lastContext.journal.readRuntimeEvents(); + expect(events.some((event: any) => event.type === 'ClaimPublished')).toBe(false); + expect( + events.some( + (event: any) => event.type === 'AttemptFailed' && event.reason === reason + ) + ).toBe(true); + }); + + it('rejects undeclared versions before any provider launch', async () => { + const config = claimConfig(); + config.checks!.consumer.consumes = [ + { claim: 'fixture.ready@2', cardinality: 'one' }, + ]; + await expect( + engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ) + ).rejects.toThrow('undeclared claim'); + expect(invocations).toEqual([]); + }); + + it.each(['emits', 'consumes'])('rejects empty %s before any provider launch', async field => { + const config: any = claimConfig(); + config.checks.producer[field] = []; + await expect( + engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ) + ).rejects.toThrow('non-empty array'); + expect(invocations).toEqual([]); + }); + + it('rejects a misspelled schema keyword before any provider launch', async () => { + const config: any = claimConfig(); + config.claim_types['fixture.ready@1'].schema.propertiez = {}; + await expect( + engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ) + ).rejects.toMatchObject({ code: 'INVALID_CLAIM_SCHEMA' }); + expect(invocations).toEqual([]); + }); + + it('rejects claim-mode OR dependencies before any provider launch', async () => { + const config = claimConfig(); + config.checks!.consumer.depends_on = 'producer|slow-sibling'; + await expect( + engine.executeGroupedChecks( + prInfo, + ['producer', 'slow-sibling', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ) + ).rejects.toMatchObject({ code: 'UNSUPPORTED_CLAIM_OR_DEPENDENCY' }); + expect(invocations).toEqual([]); + }); + + it('preserves legacy depends_on terminal ordering and result shape', async () => { + const config: VisorConfig = { + version: '1.0', + workspace: { enabled: false }, + checks: { + producer: { type: 'noop' }, + consumer: { type: 'noop', depends_on: ['producer'] }, + }, + }; + const result = await engine.executeGroupedChecks( + prInfo, + ['producer', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + expect(invocations).toEqual(['producer', 'consumer']); + expect(result.statistics.failedExecutions).toBe(0); + expect(result.results.producer[0].output).toEqual({ + value: 'ready', + ts: expect.any(Number), + }); + expect((engine as any)._lastContext.journal.readRuntimeEvents()).toEqual([]); + }); + + it('preserves legacy non-claim OR dependency behavior', async () => { + const config: VisorConfig = { + version: '1.0', + workspace: { enabled: false }, + checks: { + producer: { type: 'noop' }, + 'slow-sibling': { type: 'noop' }, + consumer: { type: 'noop', depends_on: 'producer|slow-sibling' }, + }, + }; + const run = engine.executeGroupedChecks( + prInfo, + ['producer', 'slow-sibling', 'consumer'], + undefined, + config, + 'table', + false, + 2 + ); + await slowStarted.promise; + slowGate.resolve(); + const result = await run; + expect(result.statistics.failedExecutions).toBe(0); + expect(invocations).toEqual(expect.arrayContaining(['producer', 'slow-sibling', 'consumer'])); + expect((engine as any)._lastContext.journal.readRuntimeEvents()).toEqual([]); + }); +}); diff --git a/tests/engine/nested-spec-expansion.exp-0132.engine.test.ts b/tests/engine/nested-spec-expansion.exp-0132.engine.test.ts new file mode 100644 index 000000000..2b7d4d88b --- /dev/null +++ b/tests/engine/nested-spec-expansion.exp-0132.engine.test.ts @@ -0,0 +1,229 @@ +import fs from 'fs'; +import path from 'path'; +import * as yaml from 'js-yaml'; +import { StateMachineExecutionEngine } from '../../src/state-machine-execution-engine'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { + CheckProvider, + type CheckProviderConfig, + type ExecutionContext, +} from '../../src/providers/check-provider.interface'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { ReviewSummary } from '../../src/reviewer'; +import type { VisorConfig } from '../../src/types/config'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise(settle => { + resolve = settle; + }); + return { promise, resolve }; +} + +const prInfo = { + number: 132, + title: 'Two-level scoped keyed expansion', + author: 'test', + base: 'main', + head: 'candidate', + files: [], + totalAdditions: 0, + totalDeletions: 0, + eventType: 'manual', +} as PRInfo; + +function fixtureConfig(): VisorConfig { + const fixture = path.join( + __dirname, + '../fixtures/graph-v2/nested-spec-expansion.yaml' + ); + return yaml.load(fs.readFileSync(fixture, 'utf8')) as VisorConfig; +} + +describe('EXP-0132 two-level scoped keyed expansion', () => { + const registry = CheckProviderRegistry.getInstance(); + const originalNoop = registry.getProviderOrThrow('noop'); + let engine: StateMachineExecutionEngine; + let bEnumerationGate: ReturnType; + let spec2ReviewGate: ReturnType; + let spec1Review2Gate: ReturnType; + let bEnumerationStarted: ReturnType; + let spec2ReviewStarted: ReturnType; + let spec1Review2Started: ReturnType; + let bEnumerationCompleted: boolean; + let activeProviders: number; + let peakProviders: number; + let calls: Array<{ + checkId: string; + component?: string; + spec?: string; + aliases: string[]; + scope: unknown; + scheduled: boolean; + historySize: number; + }>; + + class ControlledNoopProvider extends CheckProvider { + getName() { return 'noop'; } + getDescription() { return 'EXP-0132 deterministic fake'; } + async validateConfig() { return true; } + async isAvailable() { return true; } + getRequirements() { return []; } + getSupportedConfigKeys() { return ['type']; } + + async execute( + _pr: PRInfo, + config: CheckProviderConfig, + _dependencies?: Map, + context?: ExecutionContext + ): Promise { + const checkId = String(config.checkName); + const claims = context?.claims || {}; + const aliases = Object.keys(claims).sort(); + const claim = Object.values(claims)[0]; + const payload = claim?.payload as { id?: string } | undefined; + const scope = context?.scope as readonly Array<{ key?: string }> | undefined; + const component = scope?.[0]?.key; + const spec = scope?.[1]?.key || (scope?.length === 2 ? payload?.id : undefined); + const journal = (engine as any)._lastContext.journal; + calls.push({ + checkId, + ...(component ? { component } : {}), + ...(spec ? { spec } : {}), + aliases, + scope, + scheduled: journal.readRuntimeEvents().some( + (event: any) => + event.type === 'CheckScheduled' && + event.nodeGenerationId === context?.nodeGenerationId + ), + historySize: (config.__outputHistory as Map | undefined)?.size ?? -1, + }); + + activeProviders++; + peakProviders = Math.max(peakProviders, activeProviders); + try { + if (checkId === 'discover-components') { + return { + issues: [], + output: { + components: [ + { id: 'A', path: 'packages/a', revision: 1 }, + { id: 'B', path: 'packages/b', revision: 1 }, + ], + }, + }; + } + if (checkId === 'enumerate-spec-work') { + if (component === 'B') { + bEnumerationStarted.resolve(); + await bEnumerationGate.promise; + bEnumerationCompleted = true; + } + return { + issues: [], + output: { + specs: component === 'A' + ? [ + { id: 'spec-1', revision: 1, source: 'A/one' }, + { id: 'spec-2', revision: 1, source: 'A/two' }, + ] + : [{ id: 'spec-1', revision: 1, source: 'B/one' }], + }, + }; + } + if (checkId === 'spec-review-1' && spec === 'spec-2') { + spec2ReviewStarted.resolve(); + await spec2ReviewGate.promise; + } + if (checkId === 'spec-review-2' && component === 'A' && spec === 'spec-1') { + spec1Review2Started.resolve(); + await spec1Review2Gate.promise; + } + return { issues: [], output: { id: spec, stage: checkId } }; + } finally { + activeProviders--; + } + } + } + + beforeEach(() => { + engine = new StateMachineExecutionEngine(); + bEnumerationGate = deferred(); + spec2ReviewGate = deferred(); + spec1Review2Gate = deferred(); + bEnumerationStarted = deferred(); + spec2ReviewStarted = deferred(); + spec1Review2Started = deferred(); + bEnumerationCompleted = false; + activeProviders = 0; + peakProviders = 0; + calls = []; + registry.unregister('noop'); + registry.register(new ControlledNoopProvider()); + }); + + afterEach(() => { + registry.unregister('noop'); + registry.register(originalNoop); + }); + + it('pipelines exact component/spec scopes through one global bounded ready queue', async () => { + const config = fixtureConfig(); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-components'], + undefined, + config, + 'table', + false, + 3 + ); + + await bEnumerationStarted.promise; + await spec2ReviewStarted.promise; + await spec1Review2Started.promise; + expect(bEnumerationCompleted).toBe(false); + expect(activeProviders).toBe(3); + + spec2ReviewGate.resolve(); + bEnumerationGate.resolve(); + spec1Review2Gate.resolve(); + await run; + + const journal = (engine as any)._lastContext.journal; + const events = journal.readRuntimeEvents() as readonly any[]; + const projection = journal.getInstanceProjection(); + const nestedSpec1 = Object.values(projection.instancesById).filter( + (instance: any) => instance.scope.length === 2 && instance.itemKey === 'spec-1' + ) as any[]; + expect(nestedSpec1).toHaveLength(2); + expect(nestedSpec1[0].subgraphInstanceId).not.toBe(nestedSpec1[1].subgraphInstanceId); + expect(nestedSpec1.map(instance => instance.scope[0].key).sort()).toEqual(['A', 'B']); + expect(peakProviders).toBe(3); + expect(calls.every(call => call.scheduled)).toBe(true); + + const specCalls = calls.filter(call => call.scope && (call.scope as any[]).length === 2); + expect(specCalls.length).toBeGreaterThan(0); + expect(specCalls.every(call => call.aliases.length === 1)).toBe(true); + expect(specCalls.every(call => call.historySize === 0)).toBe(true); + expect(specCalls.every(call => (call.scope as any[])[0].key === call.component)).toBe(true); + expect(specCalls.every(call => (call.scope as any[])[1].key === call.spec)).toBe(true); + + for (const expanded of events.filter( + event => event.type === 'SubgraphExpanded' && event.scope.length === 2 + )) { + const catalogIndex = events.findIndex( + event => event.type === 'ClaimPublished' && event.claimId === expanded.catalogClaimId + ); + const activationIndex = events.findIndex( + event => + event.type === 'NodeGenerationActivated' && + event.subgraphInstanceId === expanded.subgraphInstanceId + ); + expect(catalogIndex).toBeGreaterThanOrEqual(0); + expect(activationIndex).toBeGreaterThan(catalogIndex); + } + expect(journal.replayInstanceProjection()).toEqual(projection); + }); +}); diff --git a/tests/engine/public-instance-projection.exp-0155a.engine.test.ts b/tests/engine/public-instance-projection.exp-0155a.engine.test.ts new file mode 100644 index 000000000..108b39cd2 --- /dev/null +++ b/tests/engine/public-instance-projection.exp-0155a.engine.test.ts @@ -0,0 +1,235 @@ +import fs from 'fs'; +import path from 'path'; +import * as ts from 'typescript'; +import * as yaml from 'js-yaml'; +import { + StateMachineExecutionEngine, + type ExpansionCoverageProjection, + type InstanceClaimProjection, + type InstanceProjection, +} from '../../src/sdk'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { + CheckProvider, + type CheckProviderConfig, + type ManagedAgentRun, + type ManagedRunStartRequest, +} from '../../src/providers/check-provider.interface'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { VisorConfig } from '../../src/types/config'; + +type Item = { id: string; mode: 'completed_clean'; revision: number }; +const ITEMS: Item[] = [ + { id: 'A', mode: 'completed_clean', revision: 1 }, + { id: 'B', mode: 'completed_clean', revision: 1 }, +]; +const INVOCATION = 'sha256:1111111111111111111111111111111111111111111111111111111111111111'; +const RESULT = 'sha256:2222222222222222222222222222222222222222222222222222222222222222'; +const prInfo = { title: 'public projection', files: [] } as PRInfo; + +function mappedOutcome(item: Item) { + return { + class: 'completed_clean', + invocationDigest: INVOCATION, + resultDigest: RESULT, + data: { operation: item.id, assessment: 'stable' }, + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise(settle => { + resolve = settle; + }); + return { promise, resolve }; +} + +function fixture(): VisorConfig { + return yaml.load( + fs.readFileSync(path.join(__dirname, '../fixtures/graph-v2/expansion-coverage.yaml'), 'utf8') + ) as VisorConfig; +} + +describe('EXP-0155A public instance projection', () => { + const registry = CheckProviderRegistry.getInstance(); + const originalNoop = registry.getProviderOrThrow('noop'); + let rootStarted = deferred(); + let rootRelease = deferred(); + + class ProjectionProvider extends CheckProvider { + getName() { + return 'noop'; + } + getDescription() { + return 'deterministic EXP-0155A provider'; + } + async validateConfig() { + return true; + } + async isAvailable() { + return true; + } + getRequirements() { + return []; + } + getSupportedConfigKeys() { + return ['type']; + } + async execute(_pr: PRInfo, config: CheckProviderConfig) { + if (String(config.checkName) !== 'discover-operations') throw new Error('unexpected check'); + rootStarted.resolve(); + await rootRelease.promise; + return { issues: [], output: { operations: ITEMS } }; + } + startManaged(request: ManagedRunStartRequest): ManagedAgentRun { + const item = [...request.dependencyResults.values()][0].output as Item; + const binding = request.binding; + return { + binding, + started: Promise.resolve({ version: 1, kind: 'started', binding }), + outcome: Promise.resolve({ + version: 1, + kind: 'succeeded', + binding, + summary: { issues: [], output: mappedOutcome(item) }, + }), + cancel: async () => ({ version: 1, kind: 'cancelled', binding, reason: 'deadline' }), + close: async () => ({ + version: 1, + kind: 'cleanup', + binding, + status: 'clean', + activeChildren: 0, + activeResources: 0, + }), + }; + } + } + + beforeEach(() => { + rootStarted = deferred(); + rootRelease = deferred(); + registry.unregister('noop'); + registry.register(new ProjectionProvider()); + }); + + afterEach(() => { + registry.unregister('noop'); + registry.register(originalNoop); + }); + + it('returns the exact inactive-run error through both SDK methods', () => { + const engine = new StateMachineExecutionEngine(); + for (const read of [ + () => engine.getInstanceProjection(), + () => engine.replayInstanceProjection(), + ]) { + let thrown: unknown; + try { + read(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(Error); + expect(thrown).toMatchObject({ + code: 'RUN_NOT_ACTIVE', + message: 'Instance projection requires a prior or active run', + }); + } + }); + + it('exposes selected immutable claim payloads identically live and replayed', async () => { + const engine = new StateMachineExecutionEngine(); + const run = engine.executeGroupedChecks( + prInfo, + ['discover-operations'], + undefined, + fixture(), + 'table' + ); + await rootStarted.promise; + const request = engine.requestCatalogReconciliation('discover-operations'); + rootRelease.resolve(); + await run; + + const coverage: ExpansionCoverageProjection = engine.getExpansionCoverageProjection( + request.requestId + ); + const live: InstanceProjection = engine.getInstanceProjection(); + const replay: InstanceProjection = engine.replayInstanceProjection(); + expect(replay).toEqual(live); + expect(coverage.items).toHaveLength(2); + + for (const item of coverage.items) { + expect(item.outcomeClaimId).not.toBeNull(); + const matches = Object.values(live.claimsById).filter( + claim => claim.claimId === item.outcomeClaimId + ); + expect(matches).toHaveLength(1); + const selected: InstanceClaimProjection = matches[0]; + expect(selected).toMatchObject({ + claimId: item.outcomeClaimId, + payloadFingerprint: item.outcomePayloadFingerprint, + active: true, + kind: 'generated-output', + payload: mappedOutcome(ITEMS.find(candidate => candidate.id === item.key)!), + }); + } + + for (const projection of [live, replay]) { + expect(Object.isFrozen(projection)).toBe(true); + expect(Object.isFrozen(projection.claimsById)).toBe(true); + for (const claim of Object.values(projection.claimsById)) { + expect(Object.isFrozen(claim)).toBe(true); + expect(Object.isFrozen(claim.payload as object)).toBe(true); + } + } + const selected = live.claimsById[coverage.items[0].outcomeClaimId!]; + expect(() => { + (live as { lastEventId: number }).lastEventId = -1; + }).toThrow(TypeError); + expect(() => { + (selected.payload as { data: { operation: string } }).data.operation = 'mutated'; + }).toThrow(TypeError); + expect(engine.getInstanceProjection()).toEqual(live); + expect(engine.replayInstanceProjection()).toEqual(replay); + }); + + it('has only exact AST-approved acquisition, error, and delegation statements', () => { + const file = path.join(__dirname, '../../src/state-machine-execution-engine.ts'); + const sourceText = fs.readFileSync(file, 'utf8'); + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true); + const engine = source.statements.find( + node => ts.isClassDeclaration(node) && node.name?.text === 'StateMachineExecutionEngine' + ); + if (!engine || !ts.isClassDeclaration(engine)) throw new Error('engine class not found'); + + for (const name of ['getInstanceProjection', 'replayInstanceProjection']) { + const methods = engine.members.filter( + member => ts.isMethodDeclaration(member) && member.name.getText(source) === name + ); + expect(methods).toHaveLength(1); + const method = methods[0] as ts.MethodDeclaration; + expect(method.modifiers?.map(modifier => modifier.kind)).toEqual([ + ts.SyntaxKind.PublicKeyword, + ]); + expect(method.parameters).toHaveLength(0); + expect(method.type?.getText(source)).toBe('InstanceProjection'); + const statements = [...method.body!.statements]; + expect(statements.map(statement => statement.kind)).toEqual([ + ts.SyntaxKind.VariableStatement, + ts.SyntaxKind.IfStatement, + ts.SyntaxKind.ReturnStatement, + ]); + expect(statements[0].getText(source)).toBe('const journal = this._lastContext?.journal;'); + expect(statements[1].getText(source)).toBe(`if (!journal) { + const error = new Error('Instance projection requires a prior or active run') as Error & { + code: string; + }; + error.code = 'RUN_NOT_ACTIVE'; + throw error; + }`); + expect(statements[2].getText(source)).toBe(`return journal.${name}();`); + } + }); +}); diff --git a/tests/fixtures/durable-graph-engine-continuation-child.ts b/tests/fixtures/durable-graph-engine-continuation-child.ts new file mode 100644 index 000000000..d9bccd111 --- /dev/null +++ b/tests/fixtures/durable-graph-engine-continuation-child.ts @@ -0,0 +1,320 @@ +import fs from 'fs'; +import path from 'path'; +import { StateMachineExecutionEngine } from '../../src/state-machine-execution-engine'; +import { ExecutionJournal } from '../../src/snapshot-store'; +import { compileClaimPlan } from '../../src/state-machine/graph/claim-plan'; +import { CheckProviderRegistry } from '../../src/providers/check-provider-registry'; +import { + CheckProvider, + type CheckProviderConfig, + type ExecutionContext, + type ManagedAgentRun, + type ManagedRunStartRequest, +} from '../../src/providers/check-provider.interface'; +import type { PRInfo } from '../../src/pr-analyzer'; +import type { ReviewSummary } from '../../src/reviewer'; +import type { VisorConfig } from '../../src/types/config'; + +type Item = { id: 'A' | 'B'; revision: number }; + +export const OWNER = 'discover-items'; +export const prInfo = { + number: 1, + title: 'durable graph continuation', + author: 'fixture', + base: 'main', + head: 'candidate', + files: [], + totalAdditions: 0, + totalDeletions: 0, + eventType: 'manual', +} as PRInfo; + +export function config(): VisorConfig { + return { + version: '1.0', + max_parallelism: 2, + workspace: { + enabled: true, + base_path: + process.env.VISOR_CONTINUATION_WORKSPACE_PATH || '/tmp/visor-graph-continuation-workspaces', + cleanup_on_exit: true, + }, + claim_types: { + 'items.catalog@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['items'], + properties: { + items: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'revision'], + properties: { + id: { enum: ['A', 'B'] }, + revision: { type: 'integer', minimum: 1 }, + }, + }, + }, + }, + }, + }, + 'items.item@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'revision'], + properties: { + id: { enum: ['A', 'B'] }, + revision: { type: 'integer', minimum: 1 }, + }, + }, + }, + 'items.inspected@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'revision'], + properties: { + id: { enum: ['A', 'B'] }, + revision: { type: 'integer', minimum: 1 }, + }, + }, + }, + }, + subgraphs: { + 'inspect-and-summarize': { + input: { name: 'item', claim: 'items.item@1' }, + checks: { + inspect: { + type: 'durable-fixture', + consumes: [{ claim: 'items.item@1', as: 'item' }], + emits: [{ claim: 'items.inspected@1', from: 'output' }], + }, + summarize: { + type: 'durable-fixture', + consumes: [{ claim: 'items.inspected@1', as: 'inspection' }], + }, + }, + }, + }, + checks: { + [OWNER]: { + type: 'durable-fixture', + emits: [{ claim: 'items.catalog@1', from: 'output' }], + expand: { + claim: 'items.catalog@1', + template: 'inspect-and-summarize', + items_pointer: '/items', + key_pointer: '/id', + item_claim: 'items.item@1', + }, + }, + }, + } as VisorConfig; +} + +function itemsFor(mode: string): Item[] { + return mode === 'continue' + ? [ + { id: 'A', revision: 2 }, + { id: 'B', revision: 1 }, + ] + : [ + { id: 'A', revision: 1 }, + { id: 'B', revision: 1 }, + ]; +} + +class DurableFixtureProvider extends CheckProvider { + constructor( + private readonly mode: string, + private readonly calls: unknown[] + ) { + super(); + } + + getName(): string { + return 'durable-fixture'; + } + + getDescription(): string { + return 'Deterministic durable continuation fixture provider'; + } + + async validateConfig(): Promise { + return true; + } + + async isAvailable(): Promise { + return true; + } + + getRequirements(): string[] { + return []; + } + + getSupportedConfigKeys(): string[] { + return ['type']; + } + + async execute( + _pr: PRInfo, + providerConfig: CheckProviderConfig, + _dependencyResults?: Map, + executionContext?: ExecutionContext + ): Promise { + const checkId = String(providerConfig.checkName); + const parent = (executionContext as any)?._parentContext; + this.calls.push({ + kind: 'owner', + checkId, + sessionId: parent?.sessionId, + workingDirectory: parent?.workingDirectory, + workingDirectoryExists: + typeof parent?.workingDirectory === 'string' && fs.existsSync(parent.workingDirectory), + }); + return { issues: [], output: { items: itemsFor(this.mode) } }; + } + + startManaged(request: ManagedRunStartRequest): ManagedAgentRun { + const binding = request.binding; + const item = [...request.dependencyResults.values()][0]?.output as + | { id: 'A' | 'B'; revision: number } + | undefined; + const key = item?.id || binding.scope[binding.scope.length - 1]?.key; + const checkId = request.checkConfig.checkName || binding.checkId; + this.calls.push({ + kind: 'generated', + checkId, + key, + sessionId: binding.sessionId, + binding, + }); + const output = + checkId === 'inspect' + ? { id: key, revision: item?.revision } + : { id: key, revision: item?.revision, summarized: true }; + return { + binding, + started: Promise.resolve({ version: 1, kind: 'started', binding }), + outcome: Promise.resolve({ + version: 1, + kind: 'succeeded', + binding, + summary: { issues: [], output }, + }), + cancel: async () => ({ version: 1, kind: 'cancelled', binding, reason: 'deadline' }), + close: async () => ({ + version: 1, + kind: 'cleanup', + binding, + status: 'clean', + activeChildren: 0, + activeResources: 0, + }), + }; + } +} + +function installProvider(mode: string, calls: unknown[]): () => void { + const registry = CheckProviderRegistry.getInstance(); + const previous = registry.getProvider('durable-fixture'); + if (previous) registry.unregister('durable-fixture'); + registry.register(new DurableFixtureProvider(mode, calls)); + return () => { + registry.unregister('durable-fixture'); + if (previous) registry.register(previous); + }; +} + +function writeArtifact(directory: string, name: string, value: unknown): void { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, name), JSON.stringify(value), 'utf8'); +} + +async function produce(directory: string): Promise { + const calls: unknown[] = []; + const restore = installProvider('produce', calls); + try { + const engine = new StateMachineExecutionEngine(process.cwd()); + await engine.executeGroupedChecks(prInfo, [OWNER], undefined, config()); + const context = (engine as any)._lastContext; + const checkpoint = JSON.parse( + JSON.stringify(context.journal.exportGraphCheckpoint(context.sessionId)) + ); + writeArtifact(directory, 'producer.json', { + pid: process.pid, + checkpoint, + calls, + projection: context.journal.getInstanceProjection(), + events: context.journal.readRuntimeEvents(), + }); + } finally { + restore(); + } +} + +async function continueFrom(directory: string): Promise { + const source = JSON.parse(fs.readFileSync(path.join(directory, 'producer.json'), 'utf8')); + const calls: unknown[] = []; + const restoreProvider = installProvider('continue', calls); + try { + const engine = new StateMachineExecutionEngine(process.cwd()); + const continued = await engine.continueGraphCheckpoint({ + checkpoint: source.checkpoint, + expansionOwnerCheck: OWNER, + config: config(), + prInfo, + maxParallelism: 2, + }); + const returnedCheckpoint = JSON.parse(JSON.stringify(continued.checkpoint)); + const restored = ExecutionJournal.restoreGraphCheckpoint( + compileClaimPlan(config()), + returnedCheckpoint + ); + const projection = engine.getInstanceProjection(); + const restoredLive = restored.getInstanceProjection(); + const replay = restored.replayInstanceProjection(); + const canonicalReexport = restored.exportGraphCheckpoint(returnedCheckpoint.sessionId); + const history = ((engine as any)._lastRunner.getState().historyLog as unknown[]).filter( + event => (event as any).type === 'StateTransition' + ); + writeArtifact(directory, 'continuation.json', { + pid: process.pid, + requestId: continued.requestId, + calls, + checkpoint: returnedCheckpoint, + projection, + restoredLive, + replay, + canonicalReexport, + transitions: history, + result: continued.result, + }); + } finally { + restoreProvider(); + } +} + +async function main(): Promise { + const mode = process.argv[2]; + const directory = process.argv[3]; + if (!directory || (mode !== 'produce' && mode !== 'continue')) { + throw new Error('usage: durable-graph-engine-continuation-child.ts '); + } + if (mode === 'produce') await produce(directory); + else await continueFrom(directory); +} + +if (require.main === module) { + main().catch(error => { + process.stderr.write( + `${error instanceof Error ? error.stack || error.message : String(error)}\n` + ); + process.exitCode = 1; + }); +} diff --git a/tests/fixtures/graph-v2/dynamic-component-instances.yaml b/tests/fixtures/graph-v2/dynamic-component-instances.yaml new file mode 100644 index 000000000..c8e00ae8f --- /dev/null +++ b/tests/fixtures/graph-v2/dynamic-component-instances.yaml @@ -0,0 +1,68 @@ +version: "1.0" +max_parallelism: 2 + +claim_types: + component.catalog@1: + schema: + type: object + additionalProperties: false + required: [components] + properties: + components: + type: array + items: + type: object + additionalProperties: false + required: [id, path] + properties: + id: {type: string, minLength: 1} + path: {type: string, minLength: 1} + component.item@1: + schema: + type: object + additionalProperties: false + required: [id, path] + properties: + id: {type: string, minLength: 1} + path: {type: string, minLength: 1} + component.onboarded@1: + schema: + type: object + additionalProperties: false + required: [id, findings] + properties: + id: {type: string, minLength: 1} + findings: {type: array, items: {type: string}} + +subgraphs: + onboard-component: + input: + name: component + claim: component.item@1 + checks: + inspect: + type: noop + consumes: + - claim: component.item@1 + as: component + emits: + - claim: component.onboarded@1 + from: output + summarize: + type: noop + consumes: + - claim: component.onboarded@1 + as: inspected + +checks: + discover-components: + type: noop + emits: + - claim: component.catalog@1 + from: output + expand: + claim: component.catalog@1 + template: onboard-component + items_pointer: /components + key_pointer: /id + item_claim: component.item@1 diff --git a/tests/fixtures/graph-v2/expansion-coverage.yaml b/tests/fixtures/graph-v2/expansion-coverage.yaml new file mode 100644 index 000000000..09c45d8e8 --- /dev/null +++ b/tests/fixtures/graph-v2/expansion-coverage.yaml @@ -0,0 +1,72 @@ +version: '1.0' +max_parallelism: 5 +workspace: {enabled: false} + +claim_types: + operation.catalog@1: + schema: + type: object + additionalProperties: false + required: [operations] + properties: + operations: + type: array + items: {$ref: '#/$defs/operation'} + $defs: + operation: + type: object + additionalProperties: false + required: [id, mode, revision] + properties: + id: {type: string, minLength: 1} + mode: {type: string, minLength: 1} + revision: {type: integer, minimum: 1} + operation.item@1: + schema: + type: object + additionalProperties: false + required: [id, mode, revision] + properties: + id: {type: string, minLength: 1} + mode: {type: string, minLength: 1} + revision: {type: integer, minimum: 1} + operation.outcome@1: + schema: + type: object + additionalProperties: false + required: [class, invocationDigest, resultDigest, data] + properties: + class: + enum: [completed_clean, completed_with_findings, guardrail_blocked] + findings: + type: array + invocationDigest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + resultDigest: + type: string + pattern: '^sha256:[0-9a-f]{64}$' + data: {} + +subgraphs: + assess-operation: + input: {name: operation, claim: operation.item@1} + checks: + assess: + type: noop + consumes: [{claim: operation.item@1, as: operation}] + emits: [{claim: operation.outcome@1, from: output}] + +checks: + discover-operations: + type: noop + emits: [{claim: operation.catalog@1, from: output}] + expand: + claim: operation.catalog@1 + template: assess-operation + items_pointer: /operations + key_pointer: /id + item_claim: operation.item@1 + coverage: + outcome_claim: operation.outcome@1 + class_pointer: /class diff --git a/tests/fixtures/graph-v2/nested-spec-expansion.yaml b/tests/fixtures/graph-v2/nested-spec-expansion.yaml new file mode 100644 index 000000000..2884394f9 --- /dev/null +++ b/tests/fixtures/graph-v2/nested-spec-expansion.yaml @@ -0,0 +1,127 @@ +version: '1.0' +max_parallelism: 3 +workspace: {enabled: false} + +claim_types: + component.catalog@1: + schema: + type: object + additionalProperties: false + required: [components] + properties: + components: + type: array + items: + type: object + additionalProperties: false + required: [id, path, revision] + properties: + id: {type: string, minLength: 1} + path: {type: string, minLength: 1} + revision: {type: integer, minimum: 1} + + component.item@1: + schema: + type: object + additionalProperties: false + required: [id, path, revision] + properties: + id: {type: string, minLength: 1} + path: {type: string, minLength: 1} + revision: {type: integer, minimum: 1} + + spec-work.catalog@1: + schema: + type: object + additionalProperties: false + required: [specs] + properties: + specs: + type: array + items: + type: object + additionalProperties: false + required: [id, revision, source] + properties: + id: {type: string, minLength: 1} + revision: {type: integer, minimum: 1} + source: {type: string, minLength: 1} + + spec-work.item@1: + schema: + type: object + additionalProperties: false + required: [id, revision, source] + properties: + id: {type: string, minLength: 1} + revision: {type: integer, minimum: 1} + source: {type: string, minLength: 1} + + spec-work.authored@1: &spec_result + schema: + type: object + additionalProperties: false + required: [id, stage] + properties: + id: {type: string, minLength: 1} + stage: {type: string, minLength: 1} + spec-work.review-1@1: *spec_result + spec-work.review-2@1: *spec_result + spec-work.hazards@1: *spec_result + +subgraphs: + onboard-component: + input: {name: component, claim: component.item@1} + checks: + enumerate-spec-work: + type: noop + consumes: + - {claim: component.item@1, as: component} + emits: + - {claim: spec-work.catalog@1, from: output} + expand: + claim: spec-work.catalog@1 + template: review-spec + items_pointer: /specs + key_pointer: /id + item_claim: spec-work.item@1 + + review-spec: + input: {name: spec, claim: spec-work.item@1} + checks: + author-or-refresh: + type: noop + consumes: + - {claim: spec-work.item@1, as: spec} + emits: + - {claim: spec-work.authored@1, from: output} + spec-review-1: + type: noop + consumes: + - {claim: spec-work.authored@1, as: authored} + emits: + - {claim: spec-work.review-1@1, from: output} + spec-review-2: + type: noop + consumes: + - {claim: spec-work.review-1@1, as: review_1} + emits: + - {claim: spec-work.review-2@1, from: output} + hazard-analysis: + type: noop + consumes: + - {claim: spec-work.review-2@1, as: review_2} + emits: + - {claim: spec-work.hazards@1, from: output} + +checks: + discover-components: + type: noop + emits: + - {claim: component.catalog@1, from: output} + expand: + claim: component.catalog@1 + template: onboard-component + items_pointer: /components + key_pointer: /id + item_claim: component.item@1 diff --git a/tests/fixtures/graph-v2/terminal-typed-claim.yaml b/tests/fixtures/graph-v2/terminal-typed-claim.yaml new file mode 100644 index 000000000..5faac3fab --- /dev/null +++ b/tests/fixtures/graph-v2/terminal-typed-claim.yaml @@ -0,0 +1,30 @@ +version: "1.0" +max_parallelism: 2 + +claim_types: + fixture.ready@1: + schema: + type: object + additionalProperties: false + required: + - value + properties: + value: + type: string + const: ready + +checks: + producer: + type: noop + emits: + - claim: fixture.ready@1 + from: output + + slow-sibling: + type: noop + + consumer: + type: noop + consumes: + - claim: fixture.ready@1 + cardinality: one diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 0677a3d4f..9b1b73564 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -3,6 +3,9 @@ import { ConfigManager } from '../../src/config'; import { VisorConfig } from '../../src/types/config'; import * as fs from 'fs'; import * as path from 'path'; +import * as yaml from 'js-yaml'; +import Ajv from 'ajv'; +import { configSchema } from '../../src/generated/config-schema'; // Mock fs module jest.mock('fs'); @@ -124,6 +127,189 @@ checks: }); describe('Schema Validation', () => { + it('recognizes proof-admit as a type but rejects it at the root policy boundary', () => { + const config: any = { + version: '1.0', + checks: { proof: { type: 'proof-admit' } }, + }; + const validate = new Ajv({ allErrors: true, allowUnionTypes: true, strict: false }).compile( + configSchema + ); + expect(validate(config)).toBe(true); + expect(() => configManager.validateConfig(config)).toThrow('RESERVED_PROOF_ADMISSION_ROOT'); + }); + + it('accepts the human-readable Graph v2 C1 fixture through generated schema and semantics', () => { + const realFs = jest.requireActual('fs'); + const fixturePath = path.resolve( + __dirname, + '../fixtures/graph-v2/terminal-typed-claim.yaml' + ); + const parsed = yaml.load(realFs.readFileSync(fixturePath, 'utf8')) as VisorConfig; + const validate = new Ajv({ allErrors: true, allowUnionTypes: true, strict: false }).compile( + configSchema + ); + expect(validate(parsed)).toBe(true); + expect(validate.errors).toBeNull(); + expect(() => configManager.validateConfig(parsed)).not.toThrow(); + }); + + it('accepts the human-readable Graph v2 C2 dynamic-instance fixture', () => { + const realFs = jest.requireActual('fs'); + const fixturePath = path.resolve( + __dirname, + '../fixtures/graph-v2/dynamic-component-instances.yaml' + ); + const parsed = yaml.load(realFs.readFileSync(fixturePath, 'utf8')) as VisorConfig; + const validate = new Ajv({ allErrors: true, allowUnionTypes: true, strict: false }).compile( + configSchema + ); + expect(validate(parsed)).toBe(true); + expect(validate.errors).toBeNull(); + expect(() => configManager.validateConfig(parsed)).not.toThrow(); + }); + + it('rejects malformed C2 references and executable template control flow prelaunch', () => { + const realFs = jest.requireActual('fs'); + const fixturePath = path.resolve( + __dirname, + '../fixtures/graph-v2/dynamic-component-instances.yaml' + ); + const parsed = yaml.load(realFs.readFileSync(fixturePath, 'utf8')) as any; + parsed.checks['discover-components'].expand.template = 'missing-template'; + expect(() => configManager.validateConfig(parsed)).toThrow('UNKNOWN_SUBGRAPH_TEMPLATE'); + + const routed = yaml.load(realFs.readFileSync(fixturePath, 'utf8')) as any; + routed.subgraphs['onboard-component'].checks.inspect.on_success = { + run: ['summarize'], + }; + expect(() => configManager.validateConfig(routed)).toThrow( + 'UNSUPPORTED_TEMPLATE_EXECUTION' + ); + }); + + it.each(['emits', 'consumes'])('rejects property-present empty %s before launch', field => { + const config: any = { + version: '1.0', + checks: { check: { type: 'noop', [field]: [] } }, + }; + const validate = new Ajv({ allErrors: true, allowUnionTypes: true, strict: false }).compile( + configSchema + ); + expect(validate(config)).toBe(false); + expect(validate.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ keyword: 'minItems', params: { limit: 1 } }), + ]) + ); + expect(() => configManager.validateConfig(config)).toThrow('non-empty array'); + }); + + it('rejects misspelled claim-schema keywords with a stable code', () => { + const config: any = { + version: '1.0', + claim_types: { + 'fixture.ready@1': { schema: { type: 'object', propertiez: { value: {} } } }, + }, + checks: { producer: { type: 'noop' } }, + }; + expect(() => configManager.validateConfig(config)).toThrow('INVALID_CLAIM_SCHEMA'); + }); + + it('rejects claim-mode OR tokens while preserving legacy OR validation', () => { + const config: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: { type: 'object' } } }, + checks: { + a: { type: 'noop' }, + b: { type: 'noop' }, + c: { type: 'noop', depends_on: 'a|b' }, + }, + }; + expect(() => configManager.validateConfig(config)).toThrow( + 'UNSUPPORTED_CLAIM_OR_DEPENDENCY' + ); + delete config.claim_types; + expect(() => configManager.validateConfig(config)).not.toThrow(); + }); + + it('rejects wrong or undeclared claim versions at ConfigManager boundary', () => { + const config: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: { type: 'object' } } }, + checks: { + producer: { + type: 'noop', + emits: [{ claim: 'fixture.ready@1', from: 'output' }], + }, + consumer: { + type: 'noop', + consumes: [{ claim: 'fixture.ready@2', cardinality: 'one' }], + }, + }, + }; + expect(() => configManager.validateConfig(config)).toThrow('undeclared claim'); + }); + + it('rejects duplicate claim emitters and claim-consumption cycles at ConfigManager boundary', () => { + const duplicate: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: { type: 'object' } } }, + checks: { + a: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + b: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + }, + }; + expect(() => configManager.validateConfig(duplicate)).toThrow('duplicate emitters'); + + const cycle: any = { + version: '1.0', + claim_types: { + 'fixture.a@1': { schema: { type: 'object' } }, + 'fixture.b@1': { schema: { type: 'object' } }, + }, + checks: { + a: { + type: 'noop', + emits: [{ claim: 'fixture.a@1', from: 'output' }], + consumes: [{ claim: 'fixture.b@1', cardinality: 'one' }], + }, + b: { + type: 'noop', + emits: [{ claim: 'fixture.b@1', from: 'output' }], + consumes: [{ claim: 'fixture.a@1', cardinality: 'one' }], + }, + }, + }; + expect(() => configManager.validateConfig(cycle)).toThrow( + 'Claim dependency cycle detected' + ); + }); + + it('rejects non-root claim declarations and preserves legacy configs', () => { + const nonRoot: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: { type: 'object' } } }, + checks: { + producer: { + type: 'noop', + forEach: true, + emits: [{ claim: 'fixture.ready@1', from: 'output' }], + }, + }, + }; + expect(() => configManager.validateConfig(nonRoot)).toThrow('root-scope only'); + expect(() => + configManager.validateConfig({ + version: '1.0', + checks: { + first: { type: 'noop' }, + second: { type: 'noop', depends_on: 'first' }, + }, + }) + ).not.toThrow(); + }); + it('should validate required version field', async () => { const configWithoutVersion = ` checks: diff --git a/tests/unit/providers/check-provider-registry.test.ts b/tests/unit/providers/check-provider-registry.test.ts index 4724f2f7f..1b9540c9e 100644 --- a/tests/unit/providers/check-provider-registry.test.ts +++ b/tests/unit/providers/check-provider-registry.test.ts @@ -5,6 +5,18 @@ import { } from '../../../src/providers/check-provider.interface'; import { PRInfo } from '../../../src/pr-analyzer'; import { ReviewSummary } from '../../../src/reviewer'; +import { createProofAdmitProviderForFocusedTest, ProofAdmitCheckProvider } from '../../../src/providers/proof-admit-check-provider'; +import { PROOF_CANDIDATE_CLAIM } from '../../../src/state-machine/graph/instance-plan'; +import { immutableCanonicalValue, sha256Canonical } from '../../../src/state-machine/graph/claim-kernel'; + +const admissionCandidate = (payload: any = { evidence: 'fixture' }): any => ({ + provenance: 'attempt', claimId: 'candidate-1', claim: PROOF_CANDIDATE_CLAIM, payload, + payloadFingerprint: sha256Canonical(payload), producerCheckId: 'inspect', attemptId: 'attempt-1', fence: 1, + scope: [{ key: 'A' }], parentClaimIds: [], +}); +const admissionReceipt = (candidate: any): any => ({ version: 1, kind: 'admitted', candidateClaimId: candidate.claimId, + candidateClaim: PROOF_CANDIDATE_CLAIM, candidateFingerprint: candidate.payloadFingerprint, + candidateAttemptId: candidate.attemptId, candidateFence: candidate.fence, scope: candidate.scope, parentClaimIds: candidate.parentClaimIds }); // Mock provider for testing class MockCheckProvider extends CheckProvider { @@ -71,6 +83,7 @@ describe('CheckProviderRegistry', () => { expect(providers).toContain('http_input'); expect(providers).toContain('http_client'); expect(providers).toContain('noop'); + expect(providers).toContain('proof-admit'); }); }); @@ -88,6 +101,13 @@ describe('CheckProviderRegistry', () => { registry.register(provider1); expect(() => registry.register(provider2)).toThrow("Provider 'custom' is already registered"); }); + + it('seals the reserved proof-admit name from public replacement', () => { + expect(() => registry.register(new MockCheckProvider('proof-admit'))).toThrow( + "Provider 'proof-admit' is reserved" + ); + expect(registry.getProvider('proof-admit')).toBeInstanceOf(ProofAdmitCheckProvider); + }); }); describe('unregister', () => { @@ -101,6 +121,13 @@ describe('CheckProviderRegistry', () => { it('should throw error for non-existent provider', () => { expect(() => registry.unregister('nonexistent')).toThrow("Provider 'nonexistent' not found"); }); + + it('seals the reserved proof-admit name from removal', () => { + expect(() => registry.unregister('proof-admit')).toThrow( + "Provider 'proof-admit' is reserved" + ); + expect(registry.hasProvider('proof-admit')).toBe(true); + }); }); describe('getProvider', () => { @@ -173,8 +200,8 @@ describe('CheckProviderRegistry', () => { const providers = registry.getAllProviders(); expect(providers).toContain(provider1); expect(providers).toContain(provider2); - // Reset adds 17 default providers (ai, command, script, http, http_input, http_client, noop, log, memory, github, claude-code, mcp, human-input, workflow, git-checkout, a2a, utcp) + 2 custom = 19 total - expect(providers.length).toBe(19); + // Reset adds 18 default providers, including the sealed proof-admit provider, + 2 custom = 20 total + expect(providers.length).toBe(20); }); }); @@ -225,6 +252,31 @@ describe('CheckProviderRegistry', () => { expect(registry.hasProvider('http_input')).toBe(true); expect(registry.hasProvider('http_client')).toBe(true); expect(registry.hasProvider('noop')).toBe(true); + expect(registry.getProvider('proof-admit')).toBeInstanceOf(ProofAdmitCheckProvider); }); }); + + const executeAdmission = (decision: any, candidate: any) => createProofAdmitProviderForFocusedTest({ + decide: () => decision(candidate), + }).execute({} as PRInfo, { type: 'proof-admit' } as any, undefined, { claims: { candidate } } as any); + + it.each([ + ['malformed', () => ({ kind: 'accepted', receipt: {} }), 'PROOF_ADMISSION_INVALID_RECEIPT'], + ['mismatched', (candidate: any) => ({ kind: 'accepted', receipt: immutableCanonicalValue({ ...admissionReceipt(candidate), candidateClaimId: 'forged' }) }), 'PROOF_ADMISSION_INVALID_RECEIPT'], + ['parent mismatch', (candidate: any) => ({ kind: 'accepted', receipt: immutableCanonicalValue({ ...admissionReceipt(candidate), parentClaimIds: ['forged'] }) }), 'PROOF_ADMISSION_INVALID_RECEIPT'], + ['mutable', (candidate: any) => ({ kind: 'accepted', receipt: admissionReceipt(candidate) }), 'PROOF_ADMISSION_INVALID_RECEIPT'], + ['rejected', () => ({ kind: 'rejected', reason: 'fixture' }), 'PROOF_ADMISSION_REJECTED'], + ])('publishes no authority for %s sink result', async (_name, decision, error) => { + await expect(executeAdmission(decision, admissionCandidate())).rejects.toThrow(error); + }); + + it('detaches a valid deeply frozen sink receipt', async () => { + const candidate = admissionCandidate(); + let outerDecision: any; + const result = await executeAdmission((value: any) => (outerDecision = { kind: 'accepted', receipt: immutableCanonicalValue(admissionReceipt(value)) }), candidate); + expect(Object.isFrozen(outerDecision)).toBe(false); + expect(Object.isFrozen(result.output)).toBe(true); + expect(Object.isFrozen((result.output as any).parentClaimIds)).toBe(true); + expect((result.output as any).parentClaimIds).toEqual(candidate.parentClaimIds); + }); }); diff --git a/tests/unit/snapshot-store.test.ts b/tests/unit/snapshot-store.test.ts index c43bedf35..d1d43bda2 100644 --- a/tests/unit/snapshot-store.test.ts +++ b/tests/unit/snapshot-store.test.ts @@ -1,11 +1,374 @@ -import { describe, it, expect } from '@jest/globals'; +import { describe, it, expect, jest } from '@jest/globals'; import { ExecutionJournal, ContextView, ScopePath } from '../../src/snapshot-store'; +import { compileClaimPlan } from '../../src/state-machine/graph/claim-plan'; +import { + armManagedRunDeadline, + normalizeManagedRunOutcome, + normalizeManagedRunTimeout, + snapshotManagedRun, + snapshotManagedRunStartRequest, +} from '../../src/state-machine/dispatch/managed-run'; +import type { + ManagedRunCancelReceiptV1, + ManagedRunCleanupReceiptV1, +} from '../../src/providers/check-provider.interface'; +import { sha256Canonical } from '../../src/state-machine/graph/claim-kernel'; +import { + deriveControllerItemClaimId, + deriveNodeGenerationId, + reduceInstanceEventBatch, + type GeneratedAttemptStartedEvent, + type InstanceProjection, + type InstanceRuntimeEvent, + type ManagedRunBindingV1, +} from '../../src/state-machine/graph/instance-kernel'; function makeResult(val: any) { return { issues: [], output: val } as any; } +type C2TemplateShape = 'linear' | 'two-predecessor'; + +function c2Config(templateShape: C2TemplateShape = 'linear'): any { + const claimSchema = (required: string[]) => ({ + type: 'object', + additionalProperties: false, + required, + properties: { + id: { type: 'string', minLength: 1 }, + findings: { type: 'array', items: { type: 'string' } }, + }, + }); + const checks = + templateShape === 'linear' + ? { + inspect: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: 'component.inspected@1', from: 'output' }], + }, + summarize: { + type: 'noop', + consumes: [{ claim: 'component.inspected@1', as: 'inspected' }], + }, + } + : { + first: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: 'component.first@1', from: 'output' }], + }, + second: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: 'component.second@1', from: 'output' }], + }, + join: { + type: 'noop', + depends_on: ['first', 'second'], + consumes: [{ claim: 'component.first@1', as: 'firstResult' }], + }, + }; + + return { + version: '1.0', + claim_types: { + // Deliberately permissive: reconciliation, not the catalog schema, owns pointer checks. + 'component.catalog@1': { schema: { type: 'object' } }, + 'component.item@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['id', 'path'], + properties: { + // Number/string equivalence is a hostile canonical-key case. + id: { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'number' }, + ], + }, + path: { type: 'string', minLength: 1 }, + }, + }, + }, + 'component.inspected@1': { schema: claimSchema(['id', 'findings']) }, + 'component.first@1': { schema: claimSchema(['id', 'findings']) }, + 'component.second@1': { schema: claimSchema(['id', 'findings']) }, + }, + subgraphs: { + component: { + input: { name: 'component', claim: 'component.item@1' }, + checks, + }, + }, + checks: { + discover: { + type: 'noop', + emits: [{ claim: 'component.catalog@1', from: 'output' }], + expand: { + claim: 'component.catalog@1', + template: 'component', + items_pointer: '/components', + key_pointer: '/id', + item_claim: 'component.item@1', + }, + }, + }, + }; +} + +function c2Journal(templateShape: C2TemplateShape = 'linear'): ExecutionJournal { + return new ExecutionJournal(compileClaimPlan(c2Config(templateShape))); +} + +function c4Config(): any { + const itemSchema = (required: string[]) => ({ + type: 'object', + additionalProperties: false, + required, + properties: { + id: { type: 'string', minLength: 1 }, + revision: { type: 'integer', minimum: 1 }, + source: { type: 'string', minLength: 1 }, + stage: { type: 'string', minLength: 1 }, + }, + }); + return { + version: '1.0', + claim_types: { + 'component.catalog@1': { schema: { type: 'object' } }, + 'component.item@1': { schema: itemSchema(['id', 'revision']) }, + 'spec.catalog@1': { schema: { type: 'object' } }, + 'spec.enumeration-evidence@1': { schema: { type: 'object' } }, + 'spec.item@1': { schema: itemSchema(['id', 'revision', 'source']) }, + 'spec.authored@1': { schema: itemSchema(['id', 'stage']) }, + 'spec.reviewed@1': { schema: itemSchema(['id', 'stage']) }, + }, + subgraphs: { + component: { + input: { name: 'component', claim: 'component.item@1' }, + checks: { + enumerate: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [ + { claim: 'spec.catalog@1', from: 'output' }, + { claim: 'spec.enumeration-evidence@1', from: 'output' }, + ], + expand: { + claim: 'spec.catalog@1', + template: 'spec-review', + items_pointer: '/specs', + key_pointer: '/id', + item_claim: 'spec.item@1', + }, + }, + }, + }, + 'spec-review': { + input: { name: 'spec', claim: 'spec.item@1' }, + checks: { + author: { + type: 'noop', + consumes: [{ claim: 'spec.item@1', as: 'spec' }], + emits: [{ claim: 'spec.authored@1', from: 'output' }], + }, + review: { + type: 'noop', + consumes: [{ claim: 'spec.authored@1', as: 'authored' }], + emits: [{ claim: 'spec.reviewed@1', from: 'output' }], + }, + }, + }, + }, + checks: { + discover: { + type: 'noop', + emits: [{ claim: 'component.catalog@1', from: 'output' }], + expand: { + claim: 'component.catalog@1', + template: 'component', + items_pointer: '/components', + key_pointer: '/id', + item_claim: 'component.item@1', + }, + }, + }, + }; +} + +function c4Journal(): ExecutionJournal { + return new ExecutionJournal(compileClaimPlan(c4Config())); +} + +function completeReadySpecWork(journal: ExecutionJournal): void { + while (journal.queryReadyWork().some(generation => generation.scope.length === 2)) { + for (const generation of journal.queryReadyWork().filter(value => value.scope.length === 2)) { + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + journal.completeGeneratedAttempt({ + attempt, + payload: { + id: generation.scope[generation.scope.length - 1].key, + stage: generation.checkId, + }, + }); + } + } +} + +function managedNestedBatchFixture(componentKey = 'A'): { + readonly journal: ExecutionJournal; + readonly beforeProjection: InstanceProjection; + readonly batch: readonly InstanceRuntimeEvent[]; + readonly binding: ManagedRunBindingV1; +} { + const journal = c4Journal(); + publishCatalog(journal, { + components: [ + { id: 'A', revision: 1 }, + { id: 'B', revision: 1 }, + ], + }); + const enumerate = journal.queryReadyWork().find(candidate => + candidate.checkId === 'enumerate' && + candidate.scope[candidate.scope.length - 1].key === componentKey + )!; + const attempt = journal.startGeneratedAttempt(enumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + const beforeProjection = journal.getInstanceProjection(); + const beforeEventCount = journal.readRuntimeEvents().length; + journal.completeManagedGeneratedAttempt({ + attempt, + binding, + payload: { + specs: [{ id: 'spec-1', revision: 1, source: `${componentKey}/one` }], + }, + }); + return { + journal, + beforeProjection, + batch: journal.readRuntimeEvents().slice(beforeEventCount) as readonly InstanceRuntimeEvent[], + binding, + }; +} + +function renumberBatch( + projection: InstanceProjection, + events: readonly InstanceRuntimeEvent[] +): readonly InstanceRuntimeEvent[] { + return events.map((event, index) => ({ + ...event, + eventId: projection.lastEventId + index + 1, + })) as readonly InstanceRuntimeEvent[]; +} + +function scheduleCatalogAttempt(journal: ExecutionJournal) { + const request = journal.requestCatalogReconciliation({ + sessionId: 'c2-session', + ownerCheck: 'discover', + }); + const attempt = journal.startCatalogRequestAttempt(request.requestId); + journal.scheduleCatalogRequestAttempt(attempt); + return attempt; +} + +function publishCatalog(journal: ExecutionJournal, payload: unknown) { + const attempt = scheduleCatalogAttempt(journal); + return journal.completeAttempt({ ...attempt, payload }); +} + +function expectErrorCode(action: () => unknown, code: string): void { + try { + action(); + throw new Error(`Expected ${code}`); + } catch (error) { + expect((error as { code?: string }).code).toBe(code); + } +} + +function expectDeeplyFrozen(value: unknown, seen = new Set()): void { + if (!value || typeof value !== 'object' || seen.has(value as object)) return; + const object = value as Record; + seen.add(object); + expect(Object.isFrozen(object)).toBe(true); + for (const child of Object.values(object)) expectDeeplyFrozen(child, seen); +} + +function helperManagedBinding(): ManagedRunBindingV1 { + return { + managedRunId: 'managed-helper-1', + sessionId: 'helper-session', + checkId: 'inspect', + scope: [{ + kind: 'keyed', + expansionOwnerCheck: 'discover', + key: 'A', + subgraphInstanceId: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + }], + nodeInstanceId: 'helper-node', + nodeGenerationId: 'helper-generation', + attemptId: 'helper-attempt', + fence: 7, + }; +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise(settle => { + resolve = settle; + }); + return { promise, resolve }; +} + describe('snapshot-store (journal + context view)', () => { + it('keeps one ordered versioned runtime lane and rebuilds it from events alone', () => { + const plan = compileClaimPlan({ + version: '1.0', + claim_types: { + 'fixture.ready@1': { + schema: { + type: 'object', + required: ['value'], + properties: { value: { const: 'ready' } }, + }, + }, + }, + checks: { + producer: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + consumer: { + type: 'noop', + consumes: [{ claim: 'fixture.ready@1', cardinality: 'one' }], + }, + }, + }); + const journal = new ExecutionJournal(plan); + const attempt = journal.startAttempt({ sessionId: 'runtime', checkId: 'producer', scope: [] }); + journal.scheduleCheck(attempt); + const terminal = journal.completeAttempt({ ...attempt, payload: { value: 'ready' } }); + + expect(journal.size()).toBe(0); + expect(terminal.completed.eventId).toBe(4); + expect(terminal.completed.type).toBe('AttemptCompleted'); + expect(journal.readRuntimeEvents().map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ClaimPublished', + 'AttemptCompleted', + ]); + expect(journal.readCheckClaims('consumer')).toEqual({ + 'fixture.ready@1': terminal.claims[0], + }); + expect(journal.replayClaimProjection()).toEqual(journal.getClaimProjection()); + }); + it('commits are monotonic and readVisible honors snapshot', () => { const j = new ExecutionJournal(); const session = 's1'; @@ -53,4 +416,1574 @@ describe('snapshot-store (journal + context view)', () => { const hist = cvOther.getHistory('X'); expect(hist).toHaveLength(2); }); + + it.each([ + { + name: 'duplicate canonical number/string keys', + payload: { + components: [ + { id: 1, path: 'packages/number' }, + { id: '1', path: 'packages/string' }, + ], + }, + code: 'DUPLICATE_CATALOG_KEY', + }, + { + name: 'missing items pointer', + payload: {}, + code: 'JSON_POINTER_NOT_FOUND', + }, + { + name: 'non-array items pointer', + payload: { components: { id: 'A', path: 'packages/a' } }, + code: 'INVALID_CATALOG_ITEMS', + }, + { + name: 'invalid item schema', + payload: { components: [{ id: 'A' }] }, + code: 'CLAIM_SCHEMA_INVALID', + }, + ])( + 'atomically rejects $name and records only the explicit terminal failure', + ({ payload, code }) => { + const journal = c2Journal(); + const attempt = scheduleCatalogAttempt(journal); + const eventsBeforeCompletion = journal.readRuntimeEvents(); + const claimsBeforeCompletion = journal.getClaimProjection(); + const instancesBeforeCompletion = journal.getInstanceProjection(); + + expectErrorCode(() => journal.completeAttempt({ ...attempt, payload }), code); + expect(journal.readRuntimeEvents()).toEqual(eventsBeforeCompletion); + expect(journal.getClaimProjection()).toEqual(claimsBeforeCompletion); + expect(journal.getInstanceProjection()).toEqual(instancesBeforeCompletion); + + const failed = journal.failAttempt({ + ...attempt, + reason: `rejected hostile catalog: ${code}`, + }); + expect(journal.readRuntimeEvents()).toEqual([...eventsBeforeCompletion, failed]); + expect(failed.type).toBe('AttemptFailed'); + expect(journal.replayClaimProjection()).toEqual(journal.getClaimProjection()); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + expect(journal.queryReadyWork()).toEqual([]); + } + ); + + it('emits keyed reconciliation identities in canonical key order, not catalog input order', () => { + const journal = c2Journal(); + publishCatalog(journal, { + components: [ + { id: 'B', path: 'packages/b' }, + { id: 'A', path: 'packages/a' }, + ], + }); + + const keyedDiff = (journal.readRuntimeEvents() as readonly any[]) + .filter(event => + [ + 'SubgraphExpanded', + 'ControllerItemClaimPublished', + 'NodeGenerationActivated', + ].includes(event.type) + ) + .map(event => ({ type: event.type, itemKey: event.scope[0].key })); + + expect(keyedDiff).toEqual([ + { type: 'SubgraphExpanded', itemKey: 'A' }, + { type: 'ControllerItemClaimPublished', itemKey: 'A' }, + { type: 'NodeGenerationActivated', itemKey: 'A' }, + { type: 'SubgraphExpanded', itemKey: 'B' }, + { type: 'ControllerItemClaimPublished', itemKey: 'B' }, + { type: 'NodeGenerationActivated', itemKey: 'B' }, + ]); + expect( + (journal.readRuntimeEvents() as readonly any[]) + .filter(event => event.type === 'SubgraphExpanded' && event.scope.length === 1) + .every(event => !Object.prototype.hasOwnProperty.call(event, 'catalogClaimRef')) + ).toBe(true); + }); + + it('exposes exact deeply immutable controller and generated provenance', () => { + const journal = c2Journal(); + publishCatalog(journal, { + components: [{ id: 'A', path: 'packages/a' }], + }); + + const events = journal.readRuntimeEvents() as readonly any[]; + const catalogClaim = events.find( + event => event.type === 'ClaimPublished' && event.claim === 'component.catalog@1' + ); + const itemClaim = events.find(event => event.type === 'ControllerItemClaimPublished'); + const inspect = journal.queryReadyWork().find(generation => generation.checkId === 'inspect'); + expect(catalogClaim).toBeDefined(); + expect(itemClaim).toBeDefined(); + expect(inspect).toBeDefined(); + expect( + Object.prototype.hasOwnProperty.call( + journal.getInstanceProjection().generationsById[inspect!.nodeGenerationId], + 'nestedExpansionCatalogClaimRef' + ) + ).toBe(false); + + const controllerExecution = journal.getGeneratedExecution(inspect!.nodeGenerationId); + expect(controllerExecution.claims).toEqual({ + component: { + claimId: itemClaim.claimId, + claim: 'component.item@1', + payload: { id: 'A', path: 'packages/a' }, + payloadFingerprint: itemClaim.payloadFingerprint, + producerCheckId: 'discover', + scope: itemClaim.scope, + parentClaimIds: [catalogClaim.claimId], + provenance: 'controller', + catalogClaimId: catalogClaim.claimId, + incarnation: 1, + }, + }); + expectDeeplyFrozen(controllerExecution); + + const inspectAttempt = journal.startGeneratedAttempt(inspect!.nodeGenerationId); + journal.scheduleGeneratedAttempt(inspectAttempt); + journal.completeGeneratedAttempt({ + attempt: inspectAttempt, + payload: { id: 'A', findings: ['bounded'] }, + }); + + const generatedClaim = (journal.readRuntimeEvents() as readonly any[]).find( + event => event.type === 'ClaimPublished' && event.claim === 'component.inspected@1' + ); + const summarize = journal + .queryReadyWork() + .find(generation => generation.checkId === 'summarize'); + expect(generatedClaim).toBeDefined(); + expect(summarize).toBeDefined(); + + const generatedExecution = journal.getGeneratedExecution(summarize!.nodeGenerationId); + expect(generatedExecution.claims).toEqual({ + inspected: { + claimId: generatedClaim.claimId, + claim: 'component.inspected@1', + payload: { id: 'A', findings: ['bounded'] }, + payloadFingerprint: generatedClaim.payloadFingerprint, + producerCheckId: 'inspect', + scope: generatedClaim.scope, + parentClaimIds: [itemClaim.claimId], + provenance: 'attempt', + attemptId: inspectAttempt.attemptId, + fence: inspectAttempt.fence, + }, + }); + expectDeeplyFrozen(generatedExecution); + }); + + it('waits for every compiled control predecessor after the data claim exists', () => { + const journal = c2Journal('two-predecessor'); + publishCatalog(journal, { + components: [{ id: 'A', path: 'packages/a' }], + }); + const initial = journal.queryReadyWork(); + const first = initial.find(generation => generation.checkId === 'first'); + const second = initial.find(generation => generation.checkId === 'second'); + expect(initial.map(generation => generation.checkId).sort()).toEqual(['first', 'second']); + + const firstAttempt = journal.startGeneratedAttempt(first!.nodeGenerationId); + journal.scheduleGeneratedAttempt(firstAttempt); + journal.completeGeneratedAttempt({ + attempt: firstAttempt, + payload: { id: 'A', findings: ['first complete'] }, + }); + + const afterFirst = journal.getInstanceProjection(); + expect( + Object.values(afterFirst.claimsById).some(claim => claim.claim === 'component.first@1') + ).toBe(true); + expect(journal.queryReadyWork().map(generation => generation.checkId)).toEqual(['second']); + + const secondAttempt = journal.startGeneratedAttempt(second!.nodeGenerationId); + journal.scheduleGeneratedAttempt(secondAttempt); + journal.completeGeneratedAttempt({ + attempt: secondAttempt, + payload: { id: 'A', findings: ['second complete'] }, + }); + + expect(journal.queryReadyWork().map(generation => generation.checkId)).toEqual(['join']); + const join = journal.queryReadyWork()[0]; + const joinExecution = journal.getGeneratedExecution(join.nodeGenerationId); + expect(joinExecution.node.dependencyNodeKeys).toEqual(['first', 'second']); + expect(joinExecution.claims.firstResult.claim).toBe('component.first@1'); + }); + + it('atomically publishes a managed nested catalog and exposes exact child provenance', () => { + const journal = c4Journal(); + publishCatalog(journal, { components: [{ id: 'A', revision: 1 }] }); + const enumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + expect( + journal.getInstanceProjection().generationsById[enumerate.nodeGenerationId] + .nestedExpansionCatalogClaimRef + ).toBe('spec.catalog@1'); + const attempt = journal.startGeneratedAttempt(enumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + const before = journal.readRuntimeEvents(); + + journal.completeManagedGeneratedAttempt({ + attempt, + binding, + payload: { + specs: [ + { id: 'spec-2', revision: 1, source: 'A/two' }, + { id: 'spec-1', revision: 1, source: 'A/one' }, + ], + }, + }); + + const committed = journal.readRuntimeEvents().slice(before.length) as readonly any[]; + const projection = journal.getInstanceProjection(); + const parent = projection.instancesById[enumerate.subgraphInstanceId]; + const children = Object.values(projection.instancesById) + .filter(instance => instance.parentSubgraphInstanceId === parent.subgraphInstanceId) + .sort((left, right) => left.itemKey.localeCompare(right.itemKey)); + expect(committed[0].type).toBe('ManagedRunTerminated'); + expect(committed.at(-1)?.type).toBe('AttemptCompleted'); + expect(children.map(child => child.itemKey)).toEqual(['spec-1', 'spec-2']); + expect(children.every(child => child.scope.length === 2)).toBe(true); + expect(children.every(child => child.scope[0].subgraphInstanceId === parent.subgraphInstanceId)).toBe(true); + expect(children.every(child => child.expansionOwnerNodeInstanceId === enumerate.nodeInstanceId)).toBe(true); + expect(children.every(child => child.catalogClaimRef === 'spec.catalog@1')).toBe(true); + expect(children.every(child => child.catalogProducerNodeGenerationId === enumerate.nodeGenerationId)).toBe(true); + + const catalogIndex = committed.findIndex( + event => event.type === 'ClaimPublished' && event.claim === 'spec.catalog@1' + ); + const evidenceIndex = committed.findIndex( + event => + event.type === 'ClaimPublished' && + event.claim === 'spec.enumeration-evidence@1' + ); + const firstChildIndex = committed.findIndex(event => event.type === 'SubgraphExpanded'); + expect(catalogIndex).toBeGreaterThanOrEqual(0); + expect(evidenceIndex).toBeGreaterThanOrEqual(0); + expect(firstChildIndex).toBeGreaterThan(catalogIndex); + expect(firstChildIndex).toBeGreaterThan(evidenceIndex); + for (const generation of journal.queryReadyWork().filter(value => value.scope.length === 2)) { + const execution = journal.getGeneratedExecution(generation.nodeGenerationId); + expect(Object.keys(execution.claims)).toEqual(['spec']); + expect(execution.claims.spec).toMatchObject({ + claim: 'spec.item@1', + provenance: 'controller', + scope: generation.scope, + }); + expect(execution.claims.spec.parentClaimIds).toEqual([ + projection.instancesById[generation.subgraphInstanceId].catalogClaimId, + ]); + } + expect(projection.managedRunsByAttemptId[attempt.attemptId]).toMatchObject({ + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'completed', + }); + expect(journal.replayInstanceProjection()).toEqual(projection); + }); + + it('rejects a duplicate nested key without partially publishing its catalog or children', () => { + const journal = c4Journal(); + publishCatalog(journal, { components: [{ id: 'A', revision: 1 }] }); + const enumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const attempt = journal.startGeneratedAttempt(enumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const eventsBefore = journal.readRuntimeEvents(); + const projectionBefore = journal.getInstanceProjection(); + + expectErrorCode( + () => journal.completeGeneratedAttempt({ + attempt, + payload: { + specs: [ + { id: 'duplicate', revision: 1, source: 'A/one' }, + { id: 'duplicate', revision: 1, source: 'A/two' }, + ], + }, + }), + 'DUPLICATE_CATALOG_KEY' + ); + expect(journal.readRuntimeEvents()).toEqual(eventsBefore); + expect(journal.getInstanceProjection()).toEqual(projectionBefore); + + journal.failGeneratedAttempt(attempt, 'invalid nested catalog'); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + }); + + it('rejects a malformed nested item without publishing any generated output or child event', () => { + const journal = c4Journal(); + publishCatalog(journal, { components: [{ id: 'A', revision: 1 }] }); + const enumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const attempt = journal.startGeneratedAttempt(enumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const eventsBefore = journal.readRuntimeEvents(); + const projectionBefore = journal.getInstanceProjection(); + + expectErrorCode( + () => journal.completeGeneratedAttempt({ + attempt, + payload: { specs: [{ id: 'missing-source', revision: 1 }] }, + }), + 'CLAIM_SCHEMA_INVALID' + ); + expect(journal.readRuntimeEvents()).toEqual(eventsBefore); + expect(journal.getInstanceProjection()).toEqual(projectionBefore); + journal.failGeneratedAttempt(attempt, 'malformed nested item'); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + }); + + it('tombstones descendants first and revives stable child identities with fresh claims on replacement', () => { + const journal = c4Journal(); + publishCatalog(journal, { components: [{ id: 'A', revision: 1 }] }); + const firstEnumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const firstAttempt = journal.startGeneratedAttempt(firstEnumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(firstAttempt); + journal.completeGeneratedAttempt({ + attempt: firstAttempt, + payload: { + specs: [ + { id: 'spec-1', revision: 1, source: 'A/one' }, + { id: 'spec-2', revision: 1, source: 'A/two' }, + ], + }, + }); + completeReadySpecWork(journal); + + const firstProjection = journal.getInstanceProjection(); + const firstChildren = Object.values(firstProjection.instancesById) + .filter(instance => instance.parentSubgraphInstanceId === firstEnumerate.subgraphInstanceId) + .sort((left, right) => left.itemKey.localeCompare(right.itemKey)); + const firstByKey = Object.fromEntries(firstChildren.map(child => [child.itemKey, { + subgraphInstanceId: child.subgraphInstanceId, + activeItemClaimId: child.activeItemClaimId, + incarnation: child.incarnation, + nodeInstanceIdsByTemplateNode: child.nodeInstanceIdsByTemplateNode, + sourceGenerationId: + firstProjection.activeGenerationIdByNode[child.nodeInstanceIdsByTemplateNode.author], + }])); + const beforeReplacement = journal.readRuntimeEvents().length; + + publishCatalog(journal, { components: [{ id: 'A', revision: 2 }] }); + const replacementEvents = journal.readRuntimeEvents().slice(beforeReplacement) as readonly any[]; + const childTombstoneIndexes = replacementEvents + .map((event, index) => ({ event, index })) + .filter(({ event }) => + event.type === 'SubgraphTombstoned' && + firstChildren.some(child => child.subgraphInstanceId === event.subgraphInstanceId) + ) + .map(({ index }) => index); + const parentInactivationIndex = replacementEvents.findIndex( + event => + event.type === 'NodeGenerationInactivated' && + event.nodeGenerationId === firstEnumerate.nodeGenerationId + ); + expect(childTombstoneIndexes).toHaveLength(2); + expect(parentInactivationIndex).toBeGreaterThan(Math.max(...childTombstoneIndexes)); + expect(firstChildren.every(child => + journal.getInstanceProjection().instancesById[child.subgraphInstanceId].status === 'tombstoned' + )).toBe(true); + + const secondEnumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const secondAttempt = journal.startGeneratedAttempt(secondEnumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(secondAttempt); + journal.completeGeneratedAttempt({ + attempt: secondAttempt, + payload: { + specs: [ + { id: 'spec-2', revision: 1, source: 'A/two' }, + { id: 'spec-1', revision: 1, source: 'A/one' }, + ], + }, + }); + + const revivedProjection = journal.getInstanceProjection(); + const revivedChildren = Object.values(revivedProjection.instancesById) + .filter(instance => instance.parentSubgraphInstanceId === secondEnumerate.subgraphInstanceId) + .sort((left, right) => left.itemKey.localeCompare(right.itemKey)); + expect(revivedChildren).toHaveLength(2); + for (const child of revivedChildren) { + const first = firstByKey[child.itemKey]; + expect(child.status).toBe('active'); + expect(child.subgraphInstanceId).toBe(first.subgraphInstanceId); + expect(child.nodeInstanceIdsByTemplateNode).toEqual(first.nodeInstanceIdsByTemplateNode); + expect(child.incarnation).toBe(first.incarnation + 1); + expect(child.activeItemClaimId).not.toBe(first.activeItemClaimId); + expect(revivedProjection.claimsById[first.activeItemClaimId!].active).toBe(false); + expect( + revivedProjection.activeGenerationIdByNode[child.nodeInstanceIdsByTemplateNode.author] + ).not.toBe(first.sourceGenerationId); + } + expect(secondEnumerate.nodeInstanceId).toBe(firstEnumerate.nodeInstanceId); + expect(secondEnumerate.nodeGenerationId).not.toBe(firstEnumerate.nodeGenerationId); + completeReadySpecWork(journal); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + }); + + it('applies explicit keyed child removal and addition without reusing the removed identity', () => { + const journal = c4Journal(); + publishCatalog(journal, { components: [{ id: 'A', revision: 1 }] }); + const firstEnumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const firstAttempt = journal.startGeneratedAttempt(firstEnumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(firstAttempt); + journal.completeGeneratedAttempt({ + attempt: firstAttempt, + payload: { + specs: [ + { id: 'keep', revision: 1, source: 'A/keep' }, + { id: 'remove', revision: 1, source: 'A/remove' }, + ], + }, + }); + completeReadySpecWork(journal); + const firstProjection = journal.getInstanceProjection(); + const retainedBefore = Object.values(firstProjection.instancesById).find( + instance => instance.parentSubgraphInstanceId === firstEnumerate.subgraphInstanceId && + instance.itemKey === 'keep' + )!; + const removedBefore = Object.values(firstProjection.instancesById).find( + instance => instance.parentSubgraphInstanceId === firstEnumerate.subgraphInstanceId && + instance.itemKey === 'remove' + )!; + + publishCatalog(journal, { components: [{ id: 'A', revision: 2 }] }); + const secondEnumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const secondAttempt = journal.startGeneratedAttempt(secondEnumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(secondAttempt); + journal.completeGeneratedAttempt({ + attempt: secondAttempt, + payload: { + specs: [ + { id: 'add', revision: 1, source: 'A/add' }, + { id: 'keep', revision: 1, source: 'A/keep' }, + ], + }, + }); + + const projection = journal.getInstanceProjection(); + const retainedAfter = projection.instancesById[retainedBefore.subgraphInstanceId]; + const removedAfter = projection.instancesById[removedBefore.subgraphInstanceId]; + const added = Object.values(projection.instancesById).find( + instance => instance.parentSubgraphInstanceId === secondEnumerate.subgraphInstanceId && + instance.itemKey === 'add' + )!; + expect(retainedAfter.status).toBe('active'); + expect(retainedAfter.subgraphInstanceId).toBe(retainedBefore.subgraphInstanceId); + expect(retainedAfter.activeItemClaimId).not.toBe(retainedBefore.activeItemClaimId); + expect(removedAfter.status).toBe('tombstoned'); + expect(added.status).toBe('active'); + expect(added.subgraphInstanceId).not.toBe(removedBefore.subgraphInstanceId); + expect(journal.replayInstanceProjection()).toEqual(projection); + }); + + it.each([ + ['swapped parent scope', 'INVALID_MANAGED_BATCH'], + ['foreign owner node', 'INVALID_MANAGED_BATCH'], + ['foreign catalog lineage', 'INVALID_MANAGED_BATCH'], + ['stale nested fence', 'STALE_FENCE'], + ['cross-parent child event', 'INVALID_MANAGED_BATCH'], + ['post-reconciliation claim', 'INVALID_MANAGED_BATCH'], + ])('atomically rejects %s in a managed nested terminal batch', (scenario, code) => { + const fixture = managedNestedBatchFixture('A'); + const batch = fixture.batch as readonly any[]; + const childIndex = batch.findIndex(event => event.type === 'SubgraphExpanded'); + const catalogIndex = batch.findIndex( + event => event.type === 'ClaimPublished' && event.claim === 'spec.catalog@1' + ); + const evidenceIndex = batch.findIndex( + event => + event.type === 'ClaimPublished' && + event.claim === 'spec.enumeration-evidence@1' + ); + const parentB = Object.values(fixture.beforeProjection.instancesById).find( + instance => instance.itemKey === 'B' && !instance.parentSubgraphInstanceId + )!; + let forged: readonly InstanceRuntimeEvent[]; + + if (scenario === 'swapped parent scope') { + forged = batch.map((event, index) => index === childIndex + ? { ...event, scope: [parentB.scope[0], event.scope[1]] } + : event + ) as readonly InstanceRuntimeEvent[]; + } else if (scenario === 'foreign owner node') { + forged = batch.map((event, index) => index === childIndex + ? { + ...event, + expansionOwnerNodeInstanceId: parentB.nodeInstanceIdsByTemplateNode.enumerate, + } + : event + ) as readonly InstanceRuntimeEvent[]; + } else if (scenario === 'foreign catalog lineage') { + const evidenceClaimId = batch[evidenceIndex].claimId; + const controller = batch.find( + event => event.type === 'ControllerItemClaimPublished' + ); + const activation = batch.find( + event => event.type === 'NodeGenerationActivated' && event.scope.length === 2 + ); + const controllerClaimId = deriveControllerItemClaimId({ + claim: controller.claim, + payloadFingerprint: controller.payloadFingerprint, + expansionSpecDigest: controller.expansionSpecDigest, + catalogClaimId: evidenceClaimId, + subgraphInstanceId: controller.subgraphInstanceId, + incarnation: controller.incarnation, + scope: controller.scope, + }); + const nodeGenerationId = deriveNodeGenerationId({ + nodeInstanceId: activation.nodeInstanceId, + incarnation: activation.incarnation, + itemFingerprint: activation.itemFingerprint, + executionConfigDigest: activation.executionConfigDigest, + activeInputClaimIds: [controllerClaimId], + }); + forged = batch.map(event => { + if (event.type === 'SubgraphExpanded') { + return { + ...event, + catalogClaimRef: 'spec.enumeration-evidence@1', + catalogClaimId: evidenceClaimId, + }; + } + if (event.type === 'ControllerItemClaimPublished') { + return { + ...event, + catalogClaimId: evidenceClaimId, + parentClaimIds: [evidenceClaimId], + claimId: controllerClaimId, + }; + } + if (event.type === 'NodeGenerationActivated' && event.scope.length === 2) { + return { + ...event, + activeInputClaimIds: [controllerClaimId], + nodeGenerationId, + }; + } + return event; + }) as readonly InstanceRuntimeEvent[]; + } else if (scenario === 'stale nested fence') { + forged = batch.map((event, index) => index === catalogIndex + ? { ...event, fence: event.fence + 1 } + : event + ) as readonly InstanceRuntimeEvent[]; + } else if (scenario === 'cross-parent child event') { + const foreign = managedNestedBatchFixture('B').batch.find( + event => event.type === 'SubgraphExpanded' + )!; + forged = batch.map((event, index) => index === childIndex + ? { ...foreign, eventId: event.eventId } + : event + ) as readonly InstanceRuntimeEvent[]; + } else { + const reordered = batch.filter((_, index) => index !== evidenceIndex); + const reconciliationIndex = reordered.findIndex( + event => event.type === 'SubgraphExpanded' + ); + reordered.splice(reconciliationIndex + 1, 0, batch[evidenceIndex]); + forged = renumberBatch(fixture.beforeProjection, reordered); + } + + const projectionBefore = JSON.stringify(fixture.beforeProjection); + expectErrorCode( + () => reduceInstanceEventBatch(fixture.beforeProjection, forged), + code + ); + expect(JSON.stringify(fixture.beforeProjection)).toBe(projectionBefore); + expect(fixture.journal.replayInstanceProjection()).toEqual( + fixture.journal.getInstanceProjection() + ); + }); + + it('isolates one failed child while its sibling completes the review subgraph', () => { + const journal = c4Journal(); + publishCatalog(journal, { components: [{ id: 'A', revision: 1 }] }); + const enumerate = journal.queryReadyWork().find(candidate => candidate.checkId === 'enumerate')!; + const enumerateAttempt = journal.startGeneratedAttempt(enumerate.nodeGenerationId); + journal.scheduleGeneratedAttempt(enumerateAttempt); + journal.completeGeneratedAttempt({ + attempt: enumerateAttempt, + payload: { + specs: [ + { id: 'spec-1', revision: 1, source: 'A/one' }, + { id: 'spec-2', revision: 1, source: 'A/two' }, + ], + }, + }); + + const authors = journal.queryReadyWork().filter(candidate => candidate.checkId === 'author'); + const failed = authors.find(candidate => candidate.scope[candidate.scope.length - 1].key === 'spec-1')!; + const sibling = authors.find(candidate => candidate.scope[candidate.scope.length - 1].key === 'spec-2')!; + const failedAttempt = journal.startGeneratedAttempt(failed.nodeGenerationId); + journal.scheduleGeneratedAttempt(failedAttempt); + const siblingAttempt = journal.startGeneratedAttempt(sibling.nodeGenerationId); + journal.scheduleGeneratedAttempt(siblingAttempt); + journal.failGeneratedAttempt(failedAttempt, 'isolated author failure'); + journal.completeGeneratedAttempt({ + attempt: siblingAttempt, + payload: { id: 'spec-2', stage: 'author' }, + }); + const review = journal.queryReadyWork().find(candidate => + candidate.checkId === 'review' && + candidate.subgraphInstanceId === sibling.subgraphInstanceId + )!; + const reviewAttempt = journal.startGeneratedAttempt(review.nodeGenerationId); + journal.scheduleGeneratedAttempt(reviewAttempt); + journal.completeGeneratedAttempt({ + attempt: reviewAttempt, + payload: { id: 'spec-2', stage: 'review' }, + }); + + const projection = journal.getInstanceProjection(); + expect(projection.generationsById[failed.nodeGenerationId].status).toBe('failed'); + expect(projection.generationsById[review.nodeGenerationId].status).toBe('completed'); + expect(Object.values(projection.claimsById).some(claim => + claim.active && + claim.claim === 'spec.reviewed@1' && + claim.subgraphInstanceId === sibling.subgraphInstanceId + )).toBe(true); + expect(Object.values(projection.claimsById).some(claim => + claim.active && + claim.claim === 'spec.reviewed@1' && + claim.subgraphInstanceId === failed.subgraphInstanceId + )).toBe(false); + expect(journal.replayInstanceProjection()).toEqual(projection); + }); + + it('atomically records a controller-derived managed acquisition failure', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork().find(candidate => candidate.checkId === 'inspect')!; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + const before = journal.readRuntimeEvents(); + + journal.failManagedRunAcquisition({ + attempt, + binding, + failureCode: 'MANAGED_HANDLE_INVALID', + }); + + expect(journal.readRuntimeEvents().slice(before.length).map(event => event.type)).toEqual([ + 'ManagedRunAcquisitionFailed', + 'AttemptFailed', + ]); + expect(journal.getInstanceProjection().managedRunsByAttemptId[attempt.attemptId]).toEqual({ + binding, + status: 'acquisition_failed', + controllerDecision: 'failed', + failureCode: 'MANAGED_HANDLE_INVALID', + }); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + expectErrorCode( + () => journal.failGeneratedAttempt(attempt, 'PROVIDER_EXECUTION_FAILED'), + 'STALE_FENCE' + ); + }); + + it('derives all managed authority from projection and rejects altered attempt fields', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork().find(candidate => candidate.checkId === 'inspect')!; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + const projection = journal.getInstanceProjection(); + const projectedGeneration = projection.generationsById[generation.nodeGenerationId]; + const instance = projection.instancesById[generation.subgraphInstanceId]; + + expect(binding).toMatchObject({ + sessionId: instance.sessionId, + checkId: projectedGeneration.checkId, + scope: projectedGeneration.scope, + nodeInstanceId: projectedGeneration.nodeInstanceId, + nodeGenerationId: projectedGeneration.nodeGenerationId, + attemptId: projectedGeneration.attemptId, + fence: projectedGeneration.fence, + }); + + const mutations: Array> = [ + { sessionId: 'wrong-session' }, + { checkId: 'wrong-check' }, + { scope: [] }, + { nodeInstanceId: 'wrong-instance' }, + { nodeGenerationId: 'wrong-generation' }, + { attemptId: 'wrong-attempt' }, + { fence: attempt.fence + 1 }, + ]; + for (const mutation of mutations) { + expect(() => journal.deriveManagedRunBinding({ ...attempt, ...mutation })).toThrow(); + } + }); + + it('detaches and deeply freezes managed outcome evidence before cleanup awaits', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork().find(candidate => candidate.checkId === 'inspect')!; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + const summary = { + issues: [] as Array<{ message: string }>, + output: { nested: { value: 'before' } }, + }; + + const normalized = normalizeManagedRunOutcome( + { version: 1, kind: 'succeeded', binding, summary }, + binding + ); + summary.output.nested.value = 'after'; + summary.issues.push({ message: 'late mutation' }); + + expect(normalized.kind).toBe('succeeded'); + if (normalized.kind !== 'succeeded') throw new Error('expected managed success'); + expect(normalized.summary).toEqual({ + issues: [], + output: { nested: { value: 'before' } }, + }); + expect(Object.isFrozen(normalized.summary)).toBe(true); + expect(Object.isFrozen(normalized.summary.output as object)).toBe(true); + expect( + Object.isFrozen((normalized.summary.output as { nested: object }).nested) + ).toBe(true); + }); + + it('keeps clean cleanup separate from a controller failure and replays it exactly', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork().find(candidate => candidate.checkId === 'inspect')!; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + journal.recordManagedRunCancelRequested(binding); + journal.failManagedGeneratedAttempt({ + attempt, + binding, + cleanupStatus: 'clean', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + + const managed = journal.getInstanceProjection().managedRunsByAttemptId[attempt.attemptId]; + expect(managed).toEqual({ + binding, + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + cancellationRequested: true, + }); + expect( + journal.readRuntimeEvents().slice(-5).map(event => event.type) + ).toEqual([ + 'ManagedRunAcquired', + 'ManagedRunStarted', + 'ManagedRunCancelRequested', + 'ManagedRunTerminated', + 'AttemptFailed', + ]); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + expect(JSON.stringify(journal.readRuntimeEvents())).not.toContain('activeResources'); + }); + + it('publishes a managed completion only with its clean terminal in one batch', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork().find(candidate => candidate.checkId === 'inspect')!; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + const before = journal.readRuntimeEvents(); + + expectErrorCode( + () => + journal.completeManagedGeneratedAttempt({ + attempt, + binding, + payload: { id: 'A', findings: 'not-an-array' }, + }), + 'CLAIM_SCHEMA_INVALID' + ); + expect(journal.readRuntimeEvents()).toEqual(before); + + journal.completeManagedGeneratedAttempt({ + attempt, + binding, + payload: { id: 'A', findings: ['bounded'] }, + }); + const committed = journal.readRuntimeEvents().slice(before.length); + expect(committed[0].type).toBe('ManagedRunTerminated'); + expect(committed.at(-1)?.type).toBe('AttemptCompleted'); + expect(committed.some(event => event.type === 'ClaimPublished')).toBe(true); + expect(journal.getInstanceProjection().managedRunsByAttemptId[attempt.attemptId]).toEqual({ + binding, + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'completed', + }); + expect(journal.replayInstanceProjection()).toEqual(journal.getInstanceProjection()); + }); +}); + +describe('Graph-v2 journal checkpoints', () => { + type JsonCheckpoint = any; + + function rehash(checkpoint: JsonCheckpoint): JsonCheckpoint { + checkpoint.integrity.digest = sha256Canonical({ + kind: checkpoint.kind, + version: checkpoint.version, + sessionId: checkpoint.sessionId, + graphSemanticDigest: checkpoint.graphSemanticDigest, + frontier: checkpoint.frontier, + events: checkpoint.events, + }); + return checkpoint; + } + + function checkpointWithEvents(source: ExecutionJournal, events: readonly any[]): JsonCheckpoint { + const checkpoint = JSON.parse(JSON.stringify(source.exportGraphCheckpoint('c2-session'))); + checkpoint.events = JSON.parse(JSON.stringify(events)); + checkpoint.frontier = { eventCount: events.length, lastEventId: events.length }; + return rehash(checkpoint); + } + + function completedC2Journal(): ExecutionJournal { + const source = c2Journal(); + publishCatalog(source, { components: [{ id: 'A', path: 'packages/a' }] }); + completeC2Work(source); + return source; + } + + function completeC2Work(journal: ExecutionJournal): void { + while (journal.queryReadyWork().length > 0) { + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + journal.completeGeneratedAttempt({ + attempt, + payload: generation.checkId === 'inspect' ? { id: 'A', findings: [] } : { done: true }, + }); + } + } + + it('round-trips an immutable completed Graph-v2 prefix through JSON', () => { + const source = c2Journal(); + publishCatalog(source, { components: [{ id: 'A', path: 'packages/a' }] }); + completeC2Work(source); + const checkpoint = source.exportGraphCheckpoint('c2-session'); + const restored = ExecutionJournal.restoreGraphCheckpoint( + compileClaimPlan(c2Config()), + JSON.parse(JSON.stringify(checkpoint)) + ); + + expect(restored.readRuntimeEvents()).toEqual(source.readRuntimeEvents()); + expect(restored.getClaimProjection()).toEqual(source.getClaimProjection()); + expect(restored.getInstanceProjection()).toEqual(source.getInstanceProjection()); + expect(restored.replayClaimProjection()).toEqual(restored.getClaimProjection()); + expect(restored.replayInstanceProjection()).toEqual(restored.getInstanceProjection()); + expect(restored.exportGraphCheckpoint('c2-session')).toEqual(checkpoint); + expectDeeplyFrozen(restored.exportGraphCheckpoint('c2-session')); + }); + + it('round-trips nested reconciliation after all generated work is quiescent', () => { + const source = c4Journal(); + publishCatalog(source, { components: [{ id: 'A', revision: 1 }] }); + const enumerate = source.queryReadyWork().find(value => value.checkId === 'enumerate')!; + const enumerateAttempt = source.startGeneratedAttempt(enumerate.nodeGenerationId); + source.scheduleGeneratedAttempt(enumerateAttempt); + source.completeGeneratedAttempt({ + attempt: enumerateAttempt, + payload: { specs: [{ id: 'spec-1', revision: 1, source: 'A/one' }] }, + }); + completeReadySpecWork(source); + const checkpoint = source.exportGraphCheckpoint('c2-session'); + const restored = ExecutionJournal.restoreGraphCheckpoint( + compileClaimPlan(c4Config()), + JSON.parse(JSON.stringify(checkpoint)) + ); + expect(restored.readRuntimeEvents()).toEqual(source.readRuntimeEvents()); + expect(restored.getClaimProjection()).toEqual(source.getClaimProjection()); + expect(restored.getInstanceProjection()).toEqual(source.getInstanceProjection()); + expect(restored.replayInstanceProjection()).toEqual(restored.getInstanceProjection()); + }); + + it('rejects a rehashed graph mismatch and an unhashed payload mutation', () => { + const source = c2Journal(); + publishCatalog(source, { components: [{ id: 'A', path: 'packages/a' }] }); + completeC2Work(source); + const checkpoint = source.exportGraphCheckpoint('c2-session'); + const tampered = JSON.parse(JSON.stringify(checkpoint)); + const catalogEvent = tampered.events.find((event: any) => event.type === 'ClaimPublished'); + catalogEvent.payload.components[0].path = 'packages/changed'; + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), tampered), + 'CHECKPOINT_INTEGRITY_MISMATCH' + ); + const graphChanged = JSON.parse(JSON.stringify(checkpoint)); + graphChanged.graphSemanticDigest = 'f'.repeat(64); + graphChanged.frontier.eventCount += 1; + rehash(graphChanged); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), graphChanged), + 'CHECKPOINT_GRAPH_MISMATCH' + ); + }); + + it.each([ + ['unknown envelope key', (checkpoint: any) => { checkpoint.extra = true; }], + ['wrong kind', (checkpoint: any) => { checkpoint.kind = 'other'; }], + ['wrong version', (checkpoint: any) => { checkpoint.version = 2; }], + ['alternate algorithm', (checkpoint: any) => { checkpoint.integrity.algorithm = 'sha512'; }], + ])('rejects %s at the envelope gate', (_name, mutate) => { + const source = completedC2Journal(); + const checkpoint = JSON.parse(JSON.stringify(source.exportGraphCheckpoint('c2-session'))); + mutate(checkpoint); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint), + 'INVALID_CHECKPOINT_ENVELOPE' + ); + }); + + it.each([ + ['unknown event type', (checkpoint: any) => { checkpoint.events[0].type = 'Unknown'; }], + ['extra event key', (checkpoint: any) => { checkpoint.events[0].unknown = true; }], + ['request and node hybrid', (checkpoint: any) => { + const event = checkpoint.events.find((candidate: any) => candidate.nodeGenerationId); + event.requestId = 'hybrid'; + }], + ['non-contiguous event ID', (checkpoint: any) => { checkpoint.events[1].eventId = 99; }], + ['event count mismatch', (checkpoint: any) => { checkpoint.frontier.eventCount += 1; }], + ['last event mismatch', (checkpoint: any) => { checkpoint.frontier.lastEventId += 1; }], + ])('rejects rehashed %s at the prefix gate', (_name, mutate) => { + const source = completedC2Journal(); + const checkpoint = JSON.parse(JSON.stringify(source.exportGraphCheckpoint('c2-session'))); + mutate(checkpoint); + rehash(checkpoint); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint), + 'INVALID_CHECKPOINT_PREFIX' + ); + }); + + it('checks graph binding before prefix grammar', () => { + const source = completedC2Journal(); + const checkpoint = JSON.parse(JSON.stringify(source.exportGraphCheckpoint('c2-session'))); + checkpoint.graphSemanticDigest = 'f'.repeat(64); + checkpoint.frontier.eventCount += 1; + rehash(checkpoint); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint), + 'CHECKPOINT_GRAPH_MISMATCH' + ); + }); + + it.each([ + ['mixed event session', (checkpoint: any) => { checkpoint.events[0].sessionId = 'other'; }], + ['root expansion digest', (checkpoint: any) => { + const event = checkpoint.events.find((candidate: any) => candidate.type === 'SubgraphExpanded'); + event.expansionSpecDigest = 'f'.repeat(64); + }], + ['generated activation config', (checkpoint: any) => { + const event = checkpoint.events.find((candidate: any) => candidate.type === 'NodeGenerationActivated'); + event.executionConfigDigest = 'f'.repeat(64); + }], + ])('rejects rehashed %s with its dedicated gate', (_name, mutate) => { + const source = completedC2Journal(); + const checkpoint = JSON.parse(JSON.stringify(source.exportGraphCheckpoint('c2-session'))); + mutate(checkpoint); + rehash(checkpoint); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint), + _name === 'mixed event session' ? 'CHECKPOINT_SESSION_MISMATCH' : 'CHECKPOINT_PLAN_AUTHORITY_MISMATCH' + ); + }); + + it('rejects a semantically different compiled graph after its checkpoint integrity passes', () => { + const source = completedC2Journal(); + const checkpoint = source.exportGraphCheckpoint('c2-session'); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config('two-predecessor')), checkpoint), + 'CHECKPOINT_GRAPH_MISMATCH' + ); + }); + + it('accepts an empty checkpoint and rejects every non-quiescent frontier class', () => { + const empty = new ExecutionJournal(compileClaimPlan(c2Config())).exportGraphCheckpoint('empty'); + const restored = ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), JSON.parse(JSON.stringify(empty))); + expect(restored.readRuntimeEvents()).toEqual([]); + const cases: Array<[string, () => ExecutionJournal]> = [ + ['root attempt', () => { + const journal = c2Journal(); + journal.startAttempt({ sessionId: 'c2-session', checkId: 'discover', scope: [] }); + return journal; + }], + ['pending request', () => { + const journal = c2Journal(); + journal.requestCatalogReconciliation({ sessionId: 'c2-session', ownerCheck: 'discover' }); + return journal; + }], + ['ready generation', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + return journal; + }], + ['running generation', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork()[0]; + journal.startGeneratedAttempt(generation.nodeGenerationId); + return journal; + }], + ['acquired managed run', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + journal.recordManagedRunAcquired(journal.deriveManagedRunBinding(attempt)); + return journal; + }], + ['started managed run', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + return journal; + }], + ['cancel-requested managed run', () => { + const journal = c2Journal(); + publishCatalog(journal, { components: [{ id: 'A', path: 'packages/a' }] }); + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + journal.recordManagedRunCancelRequested(binding); + return journal; + }], + ]; + for (const [name, build] of cases) { + const checkpoint = build().exportGraphCheckpoint('c2-session'); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), JSON.parse(JSON.stringify(checkpoint))), + 'CHECKPOINT_NOT_QUIESCENT' + ); + void name; + } + }); + + it.each([ + ['acquisition terminal cut', (journal: ExecutionJournal) => { + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + journal.failManagedRunAcquisition({ attempt, binding: journal.deriveManagedRunBinding(attempt), failureCode: 'MANAGED_HANDLE_INVALID' }); + return journal.readRuntimeEvents().findIndex(event => event.type === 'ManagedRunAcquisitionFailed') + 1; + }], + ['completion terminal cut', (journal: ExecutionJournal) => { + const generation = journal.queryReadyWork()[0]; + const attempt = journal.startGeneratedAttempt(generation.nodeGenerationId); + journal.scheduleGeneratedAttempt(attempt); + const binding = journal.deriveManagedRunBinding(attempt); + journal.recordManagedRunAcquired(binding); + journal.recordManagedRunStarted(binding); + journal.completeManagedGeneratedAttempt({ attempt, binding, payload: { id: 'A', findings: [] } }); + return journal.readRuntimeEvents().findIndex(event => event.type === 'ManagedRunTerminated') + 1; + }], + ])('rejects an atomic managed-terminal cut (%s)', (_name, buildCut) => { + const source = c2Journal(); + publishCatalog(source, { components: [{ id: 'A', path: 'packages/a' }] }); + const cut = buildCut(source); + const checkpoint = checkpointWithEvents(source, source.readRuntimeEvents().slice(0, cut)); + expectErrorCode( + () => ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint), + 'INVALID_CHECKPOINT_PREFIX' + ); + }); + + it('keeps a pre-checkpoint fence stale without appending an event', () => { + const source = completedC2Journal(); + const oldAttempt = source.readRuntimeEvents().find(event => + event.type === 'AttemptStarted' && !('nodeGenerationId' in event) + ) as any; + const checkpoint = source.exportGraphCheckpoint('c2-session'); + const restored = ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint); + const nextRequest = restored.requestCatalogReconciliation({ + sessionId: 'c2-session', + ownerCheck: 'discover', + }); + const nextAttempt = restored.startCatalogRequestAttempt(nextRequest.requestId); + expect(nextAttempt.fence).toBe( + source.readRuntimeEvents().filter(event => event.type === 'AttemptStarted').length + 1 + ); + const staleSchedule = () => restored.scheduleCheck({ + sessionId: oldAttempt.sessionId, + checkId: oldAttempt.checkId, + scope: oldAttempt.scope, + attemptId: oldAttempt.attemptId, + fence: oldAttempt.fence, + }); + const staleTerminal = () => restored.failAttempt({ + sessionId: oldAttempt.sessionId, + checkId: oldAttempt.checkId, + scope: oldAttempt.scope, + attemptId: oldAttempt.attemptId, + fence: oldAttempt.fence, + reason: 'stale pre-checkpoint attempt', + }); + for (const staleOperation of [staleSchedule, staleTerminal]) { + const beforeStaleCall = restored.readRuntimeEvents().length; + expectErrorCode(staleOperation, 'STALE_FENCE'); + expect(restored.readRuntimeEvents()).toHaveLength(beforeStaleCall); + } + }); + + it('reconstructs shared root/catalog ordinals and repeats restore without process-local state', () => { + const source = c2Journal(); + const root = source.startAttempt({ sessionId: 'c2-session', checkId: 'discover', scope: [] }); + source.scheduleCheck(root); + source.failAttempt({ ...root, reason: 'root failed' }); + const request = source.requestCatalogReconciliation({ sessionId: 'c2-session', ownerCheck: 'discover' }); + const catalog = source.startCatalogRequestAttempt(request.requestId); + source.scheduleCatalogRequestAttempt(catalog); + source.failAttempt({ ...catalog, reason: 'catalog failed' }); + const checkpoint = source.exportGraphCheckpoint('c2-session'); + const first = ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), JSON.parse(JSON.stringify(checkpoint))); + const second = ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), first.exportGraphCheckpoint('c2-session')); + const nextRequest = second.requestCatalogReconciliation({ sessionId: 'c2-session', ownerCheck: 'discover' }); + const nextCatalog = second.startCatalogRequestAttempt(nextRequest.requestId); + expect(nextRequest.requestOrdinal).toBe(2); + expect(nextCatalog.attemptId).toBe(sha256Canonical({ + sessionId: 'c2-session', checkId: 'discover', scope: [], ordinal: 3, + })); + expect(nextCatalog.fence).toBe(3); + }); + + it('starts a changed generation with generated ordinal one and rejects a duplicate start', () => { + const source = completedC2Journal(); + const restored = ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), source.exportGraphCheckpoint('c2-session')); + const request = restored.requestCatalogReconciliation({ sessionId: 'c2-session', ownerCheck: 'discover' }); + const catalog = restored.startCatalogRequestAttempt(request.requestId); + restored.scheduleCatalogRequestAttempt(catalog); + restored.completeAttempt({ ...catalog, payload: { components: [{ id: 'A', path: 'packages/new' }] } }); + const generation = restored.queryReadyWork().find(value => value.checkId === 'inspect')!; + const attempt = restored.startGeneratedAttempt(generation.nodeGenerationId); + expect(attempt.attemptId).toBe(sha256Canonical({ nodeGenerationId: generation.nodeGenerationId, ordinal: 1 })); + expectErrorCode(() => restored.startGeneratedAttempt(generation.nodeGenerationId), 'GENERATION_NOT_READY'); + restored.scheduleGeneratedAttempt(attempt); + restored.completeGeneratedAttempt({ attempt, payload: { id: 'A', findings: [] } }); + const downstream = restored.queryReadyWork().find(value => value.checkId === 'summarize')!; + const downstreamAttempt = restored.startGeneratedAttempt(downstream.nodeGenerationId); + restored.scheduleGeneratedAttempt(downstreamAttempt); + restored.completeGeneratedAttempt({ attempt: downstreamAttempt, payload: { done: true } }); + + const secondCheckpoint = restored.exportGraphCheckpoint('c2-session'); + const secondRestore = ExecutionJournal.restoreGraphCheckpoint( + compileClaimPlan(c2Config()), + JSON.parse(JSON.stringify(secondCheckpoint)) + ); + const furtherRequest = secondRestore.requestCatalogReconciliation({ + sessionId: 'c2-session', + ownerCheck: 'discover', + }); + const furtherCatalog = secondRestore.startCatalogRequestAttempt(furtherRequest.requestId); + expect(furtherRequest.requestOrdinal).toBe(3); + expect(furtherCatalog.attemptId).toBe(sha256Canonical({ + sessionId: 'c2-session', checkId: 'discover', scope: [], ordinal: 3, + })); + expect(furtherCatalog.fence).toBe(7); + }); + + it('reconstructs the next request, attempt, and global fence authority', () => { + const source = c2Journal(); + publishCatalog(source, { components: [{ id: 'A', path: 'packages/a' }] }); + completeC2Work(source); + const checkpoint = source.exportGraphCheckpoint('c2-session'); + const restored = ExecutionJournal.restoreGraphCheckpoint(compileClaimPlan(c2Config()), checkpoint); + const request = restored.requestCatalogReconciliation({ sessionId: 'c2-session', ownerCheck: 'discover' }); + expect(request.requestOrdinal).toBe(2); + const attempt = restored.startCatalogRequestAttempt(request.requestId); + const starts = source.readRuntimeEvents().filter(event => event.type === 'AttemptStarted').length; + expect(attempt.fence).toBe(starts + 1); + expect(attempt.attemptId).toBe(sha256Canonical({ + sessionId: 'c2-session', checkId: 'discover', scope: [], ordinal: 2, + })); + }); +}); + +describe('managed-run authority snapshots', () => { + it('starts deadline cancel and close independently without consulting provider promise methods', async () => { + jest.useFakeTimers(); + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + const intervalSpy = jest.spyOn(global, 'setInterval'); + try { + const binding = helperManagedBinding(); + const cancelReturn = deferred(); + const closeReturn = deferred(); + const providerPromiseTrap = jest.fn(() => { + throw new Error('provider settlement promise methods were consulted'); + }); + for (const promise of [cancelReturn.promise, closeReturn.promise]) { + Object.defineProperties(promise, { + then: { configurable: true, value: providerPromiseTrap }, + catch: { configurable: true, value: providerPromiseTrap }, + }); + } + + const callOrder: string[] = []; + let handle: any; + const cancel = jest.fn(function (this: unknown, reason: 'deadline', fence: number) { + expect(this).toBe(handle); + callOrder.push('cancel'); + return cancelReturn.promise; + }); + const close = jest.fn(function (this: unknown) { + expect(this).toBe(handle); + callOrder.push('close'); + return closeReturn.promise; + }); + handle = { + binding, + started: Promise.resolve({ version: 1 as const, kind: 'started' as const, binding }), + outcome: Promise.resolve({ version: 1 as const, kind: 'failed' as const, binding }), + cancel, + close, + }; + const snapshot = snapshotManagedRun(() => handle, binding); + const onCancelRequested = jest.fn(); + const deadline = armManagedRunDeadline({ + snapshot, + timeoutMs: 25, + onCancelRequested, + }); + let deadlineSettled = false; + void deadline.fired.then(() => { + deadlineSettled = true; + }); + + expect(timeoutSpy).toHaveBeenCalledTimes(1); + expect(timeoutSpy.mock.calls[0][1]).toBe(25); + expect(intervalSpy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(25); + + expect(deadline.didFire()).toBe(true); + expect(onCancelRequested).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('deadline', binding.fence); + expect(close).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['cancel', 'close']); + expect(providerPromiseTrap).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + + const closeReceipt: ManagedRunCleanupReceiptV1 = { + version: 1, + kind: 'cleanup', + binding, + status: 'clean', + activeChildren: 0, + activeResources: 0, + }; + closeReturn.resolve(closeReceipt); + await Promise.resolve(); + await Promise.resolve(); + expect(deadlineSettled).toBe(false); + + const cancelReceipt: ManagedRunCancelReceiptV1 = { + version: 1, + kind: 'cancelled', + binding, + reason: 'deadline', + }; + cancelReturn.resolve(cancelReceipt); + await expect(deadline.fired).resolves.toEqual({ + cancel: { status: 'fulfilled', value: cancelReceipt }, + close: { status: 'fulfilled', value: closeReceipt }, + cancelRequested: true, + }); + expect(deadlineSettled).toBe(true); + expect(cancel).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['cancel', 'close']); + expect(providerPromiseTrap).not.toHaveBeenCalled(); + expect(timeoutSpy).toHaveBeenCalledTimes(1); + expect(intervalSpy).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + } finally { + timeoutSpy.mockRestore(); + intervalSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + it.each([ + ['NaN', Number.NaN], + ['negative', -1], + ['positive infinity', Number.POSITIVE_INFINITY], + ])('arms one immediate, total deadline for a %s timeout', async (_name, timeoutMs) => { + jest.useFakeTimers(); + const timeoutSpy = jest.spyOn(global, 'setTimeout'); + const intervalSpy = jest.spyOn(global, 'setInterval'); + try { + const binding = helperManagedBinding(); + const cancel = jest.fn(() => Promise.resolve({ + version: 1 as const, + kind: 'cancelled' as const, + binding, + reason: 'deadline' as const, + })); + const close = jest.fn(() => Promise.resolve({ + version: 1 as const, + kind: 'cleanup' as const, + binding, + status: 'clean' as const, + activeChildren: 0 as const, + activeResources: 0 as const, + })); + const snapshot = snapshotManagedRun(() => ({ + binding, + started: Promise.resolve({ version: 1, kind: 'started', binding }), + outcome: Promise.resolve({ version: 1, kind: 'failed', binding }), + cancel, + close, + } as any), binding); + const onCancelRequested = jest.fn(); + + expect(normalizeManagedRunTimeout(timeoutMs)).toBe(0); + const deadline = armManagedRunDeadline({ snapshot, timeoutMs, onCancelRequested }); + expect(timeoutSpy).toHaveBeenCalledTimes(1); + expect(timeoutSpy.mock.calls[0][1]).toBe(0); + expect(intervalSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(0); + const settlement = await deadline.fired; + + expect(deadline.didFire()).toBe(true); + expect(onCancelRequested).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('deadline', binding.fence); + expect(close).toHaveBeenCalledTimes(1); + expect(settlement.cancel?.status).toBe('fulfilled'); + expect(settlement.close.status).toBe('fulfilled'); + expect(timeoutSpy).toHaveBeenCalledTimes(1); + expect(intervalSpy).not.toHaveBeenCalled(); + } finally { + timeoutSpy.mockRestore(); + intervalSpy.mockRestore(); + jest.useRealTimers(); + } + }); + + it.each([ + ['started thenable', 'started', { then: jest.fn() }], + ['outcome object', 'outcome', {}], + ])('rejects a handle with a non-native $s', (_name, slot, hostileValue) => { + const binding = helperManagedBinding(); + const cancel = jest.fn(); + const close = jest.fn(); + const started = Promise.resolve({ version: 1 as const, kind: 'started' as const, binding }); + const outcome = Promise.resolve({ version: 1 as const, kind: 'failed' as const, binding }); + const handle: any = { binding, started, outcome, cancel, close }; + handle[slot] = hostileValue; + + expectErrorCode( + () => snapshotManagedRun(() => handle, binding), + 'MANAGED_HANDLE_INVALID' + ); + expect(cancel).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + if ('then' in hostileValue) expect(hostileValue.then).not.toHaveBeenCalled(); + }); + + it.each(['own methods', 'per-object prototype'] as const)( + 'does not reconsult %s after started and outcome are mirrored', + async mutation => { + const binding = helperManagedBinding(); + const started = deferred(); + const outcome = deferred(); + const trap = jest.fn(() => { + throw new Error('provider promise mutation was consulted'); + }); + const snapshot = snapshotManagedRun(() => ({ + binding, + started: started.promise, + outcome: outcome.promise, + cancel: () => Promise.reject(new Error('unused cancel')), + close: () => Promise.reject(new Error('unused close')), + }), binding); + + for (const promise of [started.promise, outcome.promise]) { + if (mutation === 'own methods') { + Object.defineProperties(promise, { + then: { configurable: true, value: trap }, + catch: { configurable: true, value: trap }, + }); + } else { + Object.setPrototypeOf(promise, Object.freeze({ then: trap, catch: trap })); + } + } + + const startedValue = { version: 1 as const, kind: 'started' as const, binding }; + const outcomeValue = { version: 1 as const, kind: 'failed' as const, binding }; + started.resolve(startedValue); + outcome.resolve(outcomeValue); + + await expect(snapshot.started).resolves.toBe(startedValue); + await expect(snapshot.outcome).resolves.toBe(outcomeValue); + expect(trap).not.toHaveBeenCalled(); + } + ); + + it('mirrors first cancel and close returns and ignores later provider authority mutation', async () => { + const binding = helperManagedBinding(); + const cancelReturn = deferred(); + const closeReturn = deferred(); + const originalCancel = jest.fn(() => cancelReturn.promise); + const originalClose = jest.fn(() => closeReturn.promise); + const redirectedCancel = jest.fn(); + const redirectedClose = jest.fn(); + const redirectedStart = jest.fn(); + const handle: any = { + binding, + started: Promise.resolve({ version: 1 as const, kind: 'started' as const, binding }), + outcome: Promise.resolve({ version: 1 as const, kind: 'failed' as const, binding }), + cancel: originalCancel, + close: originalClose, + }; + const provider: { startManaged: () => any } = { + startManaged: jest.fn(() => handle), + }; + const snapshot = snapshotManagedRun(() => provider.startManaged(), binding); + + provider.startManaged = redirectedStart; + handle.cancel = redirectedCancel; + handle.close = redirectedClose; + Object.setPrototypeOf(handle, Object.freeze({ + cancel: redirectedCancel, + close: redirectedClose, + })); + + const cancelMirror = snapshot.cancelOnce('deadline', binding.fence); + const closeMirror = snapshot.closeOnce(); + const trap = jest.fn(() => { + throw new Error('provider completion mutation was consulted'); + }); + Object.defineProperties(cancelReturn.promise, { + then: { configurable: true, value: trap }, + catch: { configurable: true, value: trap }, + }); + Object.setPrototypeOf(closeReturn.promise, Object.freeze({ then: trap, catch: trap })); + + const cancelReceipt = { + version: 1 as const, + kind: 'cancelled' as const, + binding, + reason: 'deadline' as const, + }; + const closeReceipt = { + version: 1 as const, + kind: 'cleanup' as const, + binding, + status: 'clean' as const, + activeChildren: 0 as const, + activeResources: 0 as const, + }; + cancelReturn.resolve(cancelReceipt); + closeReturn.resolve(closeReceipt); + + await expect(cancelMirror).resolves.toBe(cancelReceipt); + await expect(closeMirror).resolves.toBe(closeReceipt); + expect(snapshot.cancelOnce('deadline', binding.fence)).toBe(cancelMirror); + expect(snapshot.closeOnce()).toBe(closeMirror); + expect(originalCancel).toHaveBeenCalledTimes(1); + expect(originalCancel).toHaveBeenCalledWith('deadline', binding.fence); + expect(originalClose).toHaveBeenCalledTimes(1); + expect(redirectedStart).not.toHaveBeenCalled(); + expect(redirectedCancel).not.toHaveBeenCalled(); + expect(redirectedClose).not.toHaveBeenCalled(); + expect(trap).not.toHaveBeenCalled(); + }); + + it('gives the provider a cyclic, frozen copy without freezing controller inputs', () => { + const binding = helperManagedBinding(); + const shared: any = { nested: { value: 'before' } }; + shared.self = shared; + const dependencyResults = new Map([['dep', { output: shared }]]); + const request: any = { + prInfo: { + number: 1, + title: 'fixture', + body: '', + author: 'fixture', + base: 'main', + head: 'feature', + files: [], + totalAdditions: 0, + totalDeletions: 0, + eventContext: shared, + }, + checkConfig: { type: 'managed', metadata: shared }, + dependencyResults, + executionContext: { args: shared }, + binding, + }; + + const snapshot = snapshotManagedRunStartRequest(request); + const providerShared = snapshot.prInfo.eventContext as any; + const dependency = snapshot.dependencyResults.get('dep') as any; + + expect(providerShared).not.toBe(shared); + expect(providerShared.self).toBe(providerShared); + expect((snapshot.checkConfig.metadata as any)).toBe(providerShared); + expect((snapshot.executionContext.args as any)).toBe(providerShared); + expect(dependency.output).toBe(providerShared); + expectDeeplyFrozen(snapshot); + expectDeeplyFrozen(dependency); + expect(snapshot.dependencyResults.size).toBe(1); + expect(snapshot.dependencyResults.get('dep')).toBe(dependency); + expect(snapshot.dependencyResults.has('dep')).toBe(true); + expect(Array.from(snapshot.dependencyResults.entries())).toEqual([['dep', dependency]]); + expect(Array.from(snapshot.dependencyResults.keys())).toEqual(['dep']); + expect(Array.from(snapshot.dependencyResults.values())).toEqual([dependency]); + const visited: Array<[ + string, + unknown, + ReadonlyMap, + ]> = []; + snapshot.dependencyResults.forEach((value, key, map) => { + visited.push([key, value, map]); + }); + expect(visited).toEqual([['dep', dependency, snapshot.dependencyResults]]); + expect(Array.from(snapshot.dependencyResults)).toEqual([['dep', dependency]]); + expect((snapshot.dependencyResults as any).set).toBeUndefined(); + expect((snapshot.dependencyResults as any).delete).toBeUndefined(); + expect((snapshot.dependencyResults as any).clear).toBeUndefined(); + expect(() => (snapshot.dependencyResults as any).set('late', {})).toThrow(); + + expect(Object.isFrozen(request)).toBe(false); + expect(Object.isFrozen(request.prInfo)).toBe(false); + expect(Object.isFrozen(request.checkConfig)).toBe(false); + expect(Object.isFrozen(request.executionContext)).toBe(false); + expect(Object.isFrozen(request.binding)).toBe(false); + expect(Object.isFrozen(dependencyResults)).toBe(false); + expect(Object.isFrozen(shared)).toBe(false); + expect(Object.isFrozen(shared.nested)).toBe(false); + shared.nested.value = 'after'; + dependencyResults.set('late', { output: 'late' }); + expect(providerShared.nested.value).toBe('before'); + expect(snapshot.dependencyResults.has('late')).toBe(false); + }); }); diff --git a/tests/unit/state-machine/graph/claim-kernel.test.ts b/tests/unit/state-machine/graph/claim-kernel.test.ts new file mode 100644 index 000000000..652915c1d --- /dev/null +++ b/tests/unit/state-machine/graph/claim-kernel.test.ts @@ -0,0 +1,408 @@ +import { + canonicalJson, + ClaimKernelError, + reduceClaimEvent, + replayClaimEvents, + sha256Canonical, + type AttemptStartedEvent, + type CheckScheduledEvent, +} from '../../../../src/state-machine/graph/claim-kernel'; +import { compileClaimPlan } from '../../../../src/state-machine/graph/claim-plan'; +import { ExecutionJournal } from '../../../../src/snapshot-store'; + +const schema = { + type: 'object', + additionalProperties: false, + required: ['value'], + properties: { value: { type: 'string', const: 'ready' } }, +}; + +function singleClaimPlan() { + return compileClaimPlan({ + version: '1.0', + claim_types: { 'fixture.ready@1': { schema } }, + checks: { + producer: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + consumer: { + type: 'noop', + consumes: [{ claim: 'fixture.ready@1', cardinality: 'one' }], + }, + }, + }); +} + +describe('Graph v2 C1 claim kernel', () => { + function expectClaimError(run: () => unknown, code: string, message: string): void { + try { + run(); + throw new Error(`Expected ClaimKernelError ${code}`); + } catch (error) { + expect(error).toBeInstanceOf(ClaimKernelError); + if (!(error instanceof ClaimKernelError)) throw error; + expect(error.code).toBe(code); + expect(error.message).toContain(message); + } + } + + it('canonicalizes recursively and fingerprints independently of insertion order', () => { + const left = { z: [{ b: 2, a: 1 }], a: true }; + const right = { a: true, z: [{ a: 1, b: 2 }] }; + expect(canonicalJson(left)).toBe('{"a":true,"z":[{"a":1,"b":2}]}'); + expect(sha256Canonical(left)).toBe(sha256Canonical(right)); + }); + + it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, () => true, Symbol('x')])( + 'rejects non-canonical payload %p', + value => { + expect(() => canonicalJson(value)).toThrow(ClaimKernelError); + } + ); + + it('atomically commits ordered multi-emission completion and replays identically', () => { + const plan = compileClaimPlan({ + version: '1.0', + claim_types: { + 'fixture.first@1': { schema }, + 'fixture.second@1': { schema }, + }, + checks: { + producer: { + type: 'noop', + emits: [ + { claim: 'fixture.first@1', from: 'output' }, + { claim: 'fixture.second@1', from: 'output' }, + ], + }, + consumer: { + type: 'noop', + consumes: [ + { claim: 'fixture.first@1', cardinality: 'one' }, + { claim: 'fixture.second@1', cardinality: 'one' }, + ], + }, + }, + }); + const journal = new ExecutionJournal(plan); + const producer = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(producer); + const terminal = journal.completeAttempt({ ...producer, payload: { value: 'ready' } }); + const consumer = journal.startAttempt({ sessionId: 's1', checkId: 'consumer', scope: [] }); + const scheduled = journal.scheduleCheck(consumer); + journal.completeAttempt({ ...consumer, payload: { consumed: true } }); + + const events = journal.readRuntimeEvents(); + expect(events.map(event => event.eventId)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect(events.map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'ClaimPublished', + 'ClaimPublished', + 'AttemptCompleted', + 'AttemptStarted', + 'CheckScheduled', + 'AttemptCompleted', + ]); + expect(terminal.claims.map(claim => claim.claim)).toEqual([ + 'fixture.first@1', + 'fixture.second@1', + ]); + const expectedAttemptId = sha256Canonical({ + sessionId: 's1', + checkId: 'producer', + scope: [], + ordinal: 1, + }); + const expectedFingerprint = sha256Canonical({ value: 'ready' }); + expect(producer).toMatchObject({ attemptId: expectedAttemptId, fence: 1 }); + expect(terminal.claims.map(claim => claim.payloadFingerprint)).toEqual([ + expectedFingerprint, + expectedFingerprint, + ]); + expect(terminal.claims.map(claim => claim.claimId)).toEqual( + ['fixture.first@1', 'fixture.second@1'].map(claim => + sha256Canonical({ + claim, + payloadFingerprint: expectedFingerprint, + producerCheckId: 'producer', + scope: [], + attemptId: expectedAttemptId, + fence: 1, + parentClaimIds: [], + }) + ) + ); + expect(consumer.fence).toBe(2); + expect(scheduled.claimIds).toEqual(terminal.claims.map(claim => claim.claimId)); + expect(replayClaimEvents(events, plan)).toEqual(journal.getClaimProjection()); + }); + + it('leaves no partial prefix when a later emission is invalid', () => { + const plan = compileClaimPlan({ + version: '1.0', + claim_types: { + 'fixture.first@1': { schema }, + 'fixture.second@1': { + schema: { + type: 'object', + additionalProperties: false, + required: ['value', 'second'], + properties: { value: { const: 'ready' }, second: { const: true } }, + }, + }, + }, + checks: { + producer: { + type: 'noop', + emits: [ + { claim: 'fixture.first@1', from: 'output' }, + { claim: 'fixture.second@1', from: 'output' }, + ], + }, + }, + }); + const journal = new ExecutionJournal(plan); + const attempt = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(attempt); + const before = journal.getClaimProjection(); + expectClaimError( + () => journal.completeAttempt({ ...attempt, payload: { value: 'ready' } }), + 'CLAIM_SCHEMA_INVALID', + 'failed schema validation' + ); + expect(journal.getClaimProjection()).toEqual(before); + expect(journal.readRuntimeEvents().map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + ]); + + journal.failAttempt({ ...attempt, reason: 'CLAIM_SCHEMA_INVALID' }); + expect(journal.readRuntimeEvents().map(event => event.type)).toEqual([ + 'AttemptStarted', + 'CheckScheduled', + 'AttemptFailed', + ]); + expect(journal.getClaimProjection().claims).toEqual({}); + }); + + it('derives emitter, schema, ref, parent IDs, and scheduled IDs from its owned plan', () => { + const plan = compileClaimPlan({ + version: '1.0', + claim_types: { + 'fixture.parent@1': { schema }, + 'fixture.child@1': { schema }, + }, + checks: { + parent: { type: 'noop', emits: [{ claim: 'fixture.parent@1', from: 'output' }] }, + child: { + type: 'noop', + consumes: [{ claim: 'fixture.parent@1', cardinality: 'one' }], + emits: [{ claim: 'fixture.child@1', from: 'output' }], + }, + }, + }); + const journal = new ExecutionJournal(plan); + const parent = journal.startAttempt({ sessionId: 's1', checkId: 'parent', scope: [] }); + journal.scheduleCheck(parent); + const parentTerminal = journal.completeAttempt({ ...parent, payload: { value: 'ready' } }); + const child = journal.startAttempt({ sessionId: 's1', checkId: 'child', scope: [] }); + const scheduled = journal.scheduleCheck({ + ...child, + claimIds: ['caller-forged'], + } as typeof child); + const beforeSubstitution = journal.readRuntimeEvents(); + expectClaimError( + () => + journal.completeAttempt({ + ...child, + payload: { value: 'wrong' }, + claim: 'caller.substitution@99', + schema: {}, + producerCheckId: 'caller', + parentClaimIds: ['caller-forged'], + } as typeof child & { payload: unknown }), + 'CLAIM_SCHEMA_INVALID', + 'failed schema validation' + ); + expect(journal.readRuntimeEvents()).toEqual(beforeSubstitution); + const terminal = journal.completeAttempt({ + ...child, + payload: { value: 'ready' }, + claim: 'caller.substitution@99', + schema: { not: {} }, + producerCheckId: 'caller', + parentClaimIds: ['caller-forged'], + } as typeof child & { payload: unknown }); + + expect(scheduled.claimIds).toEqual([parentTerminal.claims[0].claimId]); + expect(terminal.claims[0]).toMatchObject({ + claim: 'fixture.child@1', + producerCheckId: 'child', + parentClaimIds: [parentTerminal.claims[0].claimId], + }); + }); + + it.each([ + { name: 'wrong', claimIds: ['wrong'] }, + { name: 'missing', claimIds: [] }, + { name: 'extra', claimIds: ['ACTIVE', 'extra'] }, + { name: 'duplicate', claimIds: ['ACTIVE', 'ACTIVE'] }, + ])('rejects $name scheduled claim IDs in live reduction and replay', ({ claimIds }) => { + const plan = singleClaimPlan(); + const journal = new ExecutionJournal(plan); + const producer = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(producer); + const terminal = journal.completeAttempt({ ...producer, payload: { value: 'ready' } }); + const consumer = journal.startAttempt({ sessionId: 's1', checkId: 'consumer', scope: [] }); + const active = terminal.claims[0].claimId; + const forged: CheckScheduledEvent = { + version: 1, + type: 'CheckScheduled', + eventId: journal.getClaimProjection().lastEventId + 1, + sessionId: consumer.sessionId, + checkId: consumer.checkId, + scope: [], + attemptId: consumer.attemptId, + fence: consumer.fence, + claimIds: claimIds.map(value => (value === 'ACTIVE' ? active : value)), + }; + expectClaimError( + () => reduceClaimEvent(journal.getClaimProjection(), forged, plan), + 'INVALID_SCHEDULED_CLAIMS', + 'exact declared active claims' + ); + expectClaimError( + () => replayClaimEvents([...journal.readRuntimeEvents(), forged], plan), + 'INVALID_SCHEDULED_CLAIMS', + 'exact declared active claims' + ); + }); + + it('rejects an inactive older-generation scheduled claim in live reduction and replay', () => { + const plan = singleClaimPlan(); + const journal = new ExecutionJournal(plan); + const first = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(first); + const oldClaim = journal.completeAttempt({ ...first, payload: { value: 'ready' } }).claims[0]; + const second = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(second); + const activeClaim = journal.completeAttempt({ ...second, payload: { value: 'ready' } }).claims[0]; + expect(activeClaim.claimId).not.toBe(oldClaim.claimId); + const consumer = journal.startAttempt({ sessionId: 's1', checkId: 'consumer', scope: [] }); + const forged: CheckScheduledEvent = { + version: 1, + type: 'CheckScheduled', + eventId: journal.getClaimProjection().lastEventId + 1, + sessionId: consumer.sessionId, + checkId: consumer.checkId, + scope: [], + attemptId: consumer.attemptId, + fence: consumer.fence, + claimIds: [oldClaim.claimId], + }; + expectClaimError( + () => reduceClaimEvent(journal.getClaimProjection(), forged, plan), + 'INVALID_SCHEDULED_CLAIMS', + 'exact declared active claims' + ); + expectClaimError( + () => replayClaimEvents([...journal.readRuntimeEvents(), forged], plan), + 'INVALID_SCHEDULED_CLAIMS', + 'exact declared active claims' + ); + }); + + it('deeply isolates authored inputs, appended events, returned events, and projections', () => { + const authored: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: JSON.parse(JSON.stringify(schema)) } }, + checks: { + producer: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + consumer: { + type: 'noop', + consumes: [{ claim: 'fixture.ready@1', cardinality: 'one' }], + }, + }, + }; + const plan = compileClaimPlan(authored); + const journal = new ExecutionJournal(plan); + authored.claim_types['fixture.ready@1'].schema.properties.value.const = 'corrupted'; + + const scope: Array<{ check: string; index: number }> = []; + const payload = { value: 'ready' }; + const attempt = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope }); + journal.scheduleCheck(attempt); + journal.completeAttempt({ ...attempt, payload }); + payload.value = 'corrupted'; + scope.push({ check: 'caller', index: 1 }); + + const events: any = journal.readRuntimeEvents(); + const projection: any = journal.getClaimProjection(); + expect(Object.isFrozen(events)).toBe(true); + expect(Object.isFrozen(events[2].payload)).toBe(true); + expect(Object.isFrozen(projection)).toBe(true); + try { + events[2].payload.value = 'mutated'; + events.push({ type: 'forged' }); + projection.activeClaimIdsByRef['fixture.ready@1'] = 'forged'; + } catch {} + + const reread = journal.readRuntimeEvents(); + expect((reread[2] as any).payload).toEqual({ value: 'ready' }); + expect(journal.readCheckClaims('consumer')['fixture.ready@1'].payload).toEqual({ + value: 'ready', + }); + expect(journal.replayClaimProjection()).toEqual(journal.getClaimProjection()); + }); + + it('requires fences to advance after both completed and failed attempts', () => { + const plan = singleClaimPlan(); + const journal = new ExecutionJournal(plan); + const first = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(first); + journal.completeAttempt({ ...first, payload: { value: 'ready' } }); + + const staleAfterCompletion: AttemptStartedEvent = { + ...first, + eventId: journal.getClaimProjection().lastEventId + 1, + }; + expectClaimError( + () => reduceClaimEvent(journal.getClaimProjection(), staleAfterCompletion, plan), + 'STALE_FENCE', + 'advance monotonically' + ); + const second = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + expect(second.fence).toBeGreaterThan(first.fence); + journal.failAttempt({ ...second, reason: 'TEST_FAILURE' }); + + const staleAfterFailure: AttemptStartedEvent = { + ...second, + eventId: journal.getClaimProjection().lastEventId + 1, + }; + expectClaimError( + () => replayClaimEvents([...journal.readRuntimeEvents(), staleAfterFailure], plan), + 'STALE_FENCE', + 'advance monotonically' + ); + const third = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + expect(third.fence).toBeGreaterThan(second.fence); + }); + + it('rejects stale-fence atomic publication without changing journal truth', () => { + const plan = singleClaimPlan(); + const journal = new ExecutionJournal(plan); + const stale = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(stale); + const current = journal.startAttempt({ sessionId: 's1', checkId: 'producer', scope: [] }); + journal.scheduleCheck(current); + const beforeEvents = journal.readRuntimeEvents(); + const beforeProjection = journal.getClaimProjection(); + expectClaimError( + () => journal.completeAttempt({ ...stale, payload: { value: 'ready' } }), + 'STALE_FENCE', + 'is not current' + ); + expect(journal.readRuntimeEvents()).toEqual(beforeEvents); + expect(journal.getClaimProjection()).toEqual(beforeProjection); + journal.completeAttempt({ ...current, payload: { value: 'ready' } }); + }); +}); diff --git a/tests/unit/state-machine/graph/claim-plan.test.ts b/tests/unit/state-machine/graph/claim-plan.test.ts new file mode 100644 index 000000000..84954fffb --- /dev/null +++ b/tests/unit/state-machine/graph/claim-plan.test.ts @@ -0,0 +1,195 @@ +import { compileClaimPlan, ClaimPlanError } from '../../../../src/state-machine/graph/claim-plan'; +import { ClaimKernelError } from '../../../../src/state-machine/graph/claim-kernel'; +import { + PROOF_ADMIT_PROVIDER_TYPE, + PROOF_ADMITTED_RECEIPT_CLAIM, + PROOF_CANDIDATE_CLAIM, +} from '../../../../src/state-machine/graph/instance-plan'; + +const schema = { + type: 'object', + required: ['value'], + properties: { value: { type: 'string' } }, +}; + +describe('Graph v2 C1 claim plan', () => { + const expectRootReserved = (config: any) => { + let error: unknown; + try { compileClaimPlan(config); } catch (candidate) { error = candidate; } + expect((error as ClaimPlanError).code).toBe('RESERVED_PROOF_ADMISSION_ROOT'); + }; + + it.each([ + ['provider without claim plan', { version: '1.0', checks: { proof: { type: PROOF_ADMIT_PROVIDER_TYPE } } }], + ['reserved emission', { version: '1.0', checks: { proof: { type: 'noop', emits: [{ claim: PROOF_CANDIDATE_CLAIM, from: 'output' }] } } }], + ['reserved consumption', { version: '1.0', checks: { proof: { type: 'noop', consumes: [{ claim: PROOF_ADMITTED_RECEIPT_CLAIM, cardinality: 'one' }] } } }], + ])('rejects root %s before inactive claim-plan return', (_name, config) => expectRootReserved(config)); + + it('compiles exact consumption into immutable effective dependencies without authored mutation', () => { + const authoredSchema = JSON.parse(JSON.stringify(schema)); + const config: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: authoredSchema } }, + checks: { + producer: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + sibling: { type: 'noop' }, + consumer: { + type: 'noop', + depends_on: ['sibling'], + consumes: [{ claim: 'fixture.ready@1', cardinality: 'one' }], + }, + }, + }; + const authored = JSON.parse(JSON.stringify(config)); + + const plan = compileClaimPlan(config); + + expect(plan.active).toBe(true); + expect(plan.emitterByClaim['fixture.ready@1']).toBe('producer'); + expect(plan.effectiveDependenciesByCheck.consumer).toEqual(['sibling', 'producer']); + expect(config).toEqual(authored); + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(plan.claimTypes['fixture.ready@1'].schema)).toBe(true); + expect(Object.isFrozen(plan.emissionsByCheck.producer[0])).toBe(true); + expect(Object.isFrozen(plan.consumptionsByCheck.consumer[0])).toBe(true); + expect(Object.isFrozen(plan.effectiveDependenciesByCheck.consumer)).toBe(true); + + config.claim_types['fixture.ready@1'].schema.required.push('changed-after-compile'); + expect(plan.claimTypes['fixture.ready@1'].schema).toEqual(schema); + }); + + it.each(['emits', 'consumes'])('rejects a property-present empty %s declaration', field => { + const config: any = { + version: '1.0', + checks: { check: { type: 'noop', [field]: [] } }, + }; + try { + compileClaimPlan(config); + throw new Error('expected empty declaration rejection'); + } catch (error) { + expect(error).toBeInstanceOf(ClaimPlanError); + expect((error as ClaimPlanError).code).toBe('EMPTY_CLAIM_DECLARATION'); + } + }); + + it('strictly compiles schemas before launch', () => { + const config: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema: { type: 'object', propertiez: {} } } }, + checks: { producer: { type: 'noop' } }, + }; + try { + compileClaimPlan(config); + throw new Error('expected strict schema rejection'); + } catch (error) { + expect(error).toBeInstanceOf(ClaimKernelError); + expect((error as ClaimKernelError).code).toBe('INVALID_CLAIM_SCHEMA'); + } + }); + + it('rejects OR dependency tokens only in claim mode', () => { + const claimConfig: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema } }, + checks: { + a: { type: 'noop' }, + b: { type: 'noop' }, + c: { type: 'noop', depends_on: 'a|b' }, + }, + }; + try { + compileClaimPlan(claimConfig); + throw new Error('expected claim-mode OR rejection'); + } catch (error) { + expect(error).toBeInstanceOf(ClaimPlanError); + expect((error as ClaimPlanError).code).toBe('UNSUPPORTED_CLAIM_OR_DEPENDENCY'); + } + + delete claimConfig.claim_types; + expect(compileClaimPlan(claimConfig).effectiveDependenciesByCheck.c).toEqual(['a', 'b']); + }); + + it.each([ + { + name: 'wrong version', + mutate: (config: any) => { + config.checks.consumer.consumes[0].claim = 'fixture.ready@2'; + }, + message: 'undeclared claim', + }, + { + name: 'invalid reference', + mutate: (config: any) => { + config.claim_types = { fixture: { schema } }; + config.checks.producer.emits[0].claim = 'fixture'; + config.checks.consumer.consumes[0].claim = 'fixture'; + }, + message: 'Invalid claim reference', + }, + { + name: 'duplicate emitter', + mutate: (config: any) => { + config.checks.other = { + type: 'noop', + emits: [{ claim: 'fixture.ready@1', from: 'output' }], + }; + }, + message: 'duplicate emitters', + }, + { + name: 'unsupported root scope', + mutate: (config: any) => { + config.checks.producer.forEach = true; + }, + message: 'root-scope only', + }, + ])('rejects $name', ({ mutate, message }) => { + const config: any = { + version: '1.0', + claim_types: { 'fixture.ready@1': { schema } }, + checks: { + producer: { type: 'noop', emits: [{ claim: 'fixture.ready@1', from: 'output' }] }, + consumer: { + type: 'noop', + consumes: [{ claim: 'fixture.ready@1', cardinality: 'one' }], + }, + }, + }; + mutate(config); + expect(() => compileClaimPlan(config)).toThrow(message); + }); + + it('rejects a claim-consumption cycle', () => { + const config: any = { + version: '1.0', + claim_types: { + 'fixture.a@1': { schema }, + 'fixture.b@1': { schema }, + }, + checks: { + a: { + type: 'noop', + emits: [{ claim: 'fixture.a@1', from: 'output' }], + consumes: [{ claim: 'fixture.b@1', cardinality: 'one' }], + }, + b: { + type: 'noop', + emits: [{ claim: 'fixture.b@1', from: 'output' }], + consumes: [{ claim: 'fixture.a@1', cardinality: 'one' }], + }, + }, + }; + expect(() => compileClaimPlan(config)).toThrow(ClaimPlanError); + expect(() => compileClaimPlan(config)).toThrow('Claim dependency cycle detected'); + }); + + it('preserves legacy dependency-only configuration', () => { + const config: any = { + version: '1.0', + checks: { a: { type: 'noop' }, b: { type: 'noop', depends_on: 'a' } }, + }; + const plan = compileClaimPlan(config); + expect(plan.active).toBe(false); + expect(plan.effectiveDependenciesByCheck).toEqual({ a: [], b: ['a'] }); + }); +}); diff --git a/tests/unit/state-machine/graph/instance-kernel.test.ts b/tests/unit/state-machine/graph/instance-kernel.test.ts new file mode 100644 index 000000000..2499a81f9 --- /dev/null +++ b/tests/unit/state-machine/graph/instance-kernel.test.ts @@ -0,0 +1,1234 @@ +import { + canonicalJson, + sha256Canonical, +} from '../../../../src/state-machine/graph/claim-kernel'; +import { + canonicalCatalogKey, + createInitialInstanceProjection, + deriveCatalogRequestId, + deriveControllerItemClaimId, + deriveItemFingerprint, + deriveManagedRunId, + deriveNodeGenerationId, + deriveNodeInstanceId, + deriveSubgraphInstanceId, + InstanceKernelError, + queryReadyGenerations, + reduceInstanceEvent, + reduceInstanceEventBatch, + replayInstanceEvents, + requireKeyedScopePath, + validateTaggedScopePath, + type ControllerItemClaimPublishedEvent, + type GeneratedAttemptCompletedEvent, + type GeneratedAttemptStartedEvent, + type GeneratedCheckScheduledEvent, + type GeneratedClaimPublishedEvent, + type InstanceProjection, + type InstanceRuntimeEvent, + type KeyedScopePath, + type ManagedRunAcquiredEvent, + type ManagedRunAcquisitionFailedEvent, + type ManagedRunBindingV1, + type ManagedRunCancelRequestedEvent, + type ManagedRunFailureCode, + type ManagedRunStartedEvent, + type ManagedRunTerminatedEvent, + type NodeGenerationActivatedEvent, + type NodeGenerationInactivatedEvent, + type SubgraphExpandedEvent, + type SubgraphTombstonedEvent, +} from '../../../../src/state-machine/graph/instance-kernel'; + +const sessionId = 'session-1'; +const expansionOwnerCheck = 'discover-components'; +const graphSemanticDigest = sha256Canonical({ graph: 1 }); +const expansionSpecDigest = sha256Canonical({ expansion: 1 }); +const templateDigest = sha256Canonical({ template: 1 }); +const catalogClaimId = sha256Canonical({ catalog: 1 }); +const itemClaimRef = 'component.item@1'; +const outputClaimRef = 'component.onboarded@1'; + +function expectKernelError(run: () => unknown, code: string): void { + try { + run(); + throw new Error(`Expected InstanceKernelError ${code}`); + } catch (error) { + expect(error).toBeInstanceOf(InstanceKernelError); + if (!(error instanceof InstanceKernelError)) throw error; + expect(error.code).toBe(code); + } +} + +function expectAnyKernelError(run: () => unknown): void { + try { + run(); + throw new Error('Expected InstanceKernelError'); + } catch (error) { + expect(error).toBeInstanceOf(InstanceKernelError); + } +} + +function deepFreeze(value: T): T { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) { + deepFreeze(nested); + } + Object.freeze(value); + } + return value; +} + +function expectRecursivelyFrozen(value: unknown): void { + if (value === null || typeof value !== 'object') return; + expect(Object.isFrozen(value)).toBe(true); + for (const nested of Object.values(value as Record)) { + expectRecursivelyFrozen(nested); + } +} + +function instanceIdentity(itemKey = 'A') { + const subgraphInstanceId = deriveSubgraphInstanceId({ + graphSemanticDigest, + expansionOwnerCheck, + parentSubgraphInstanceId: null, + templateDigest, + itemKey, + }); + const scope: KeyedScopePath = [ + { kind: 'keyed', expansionOwnerCheck, key: itemKey, subgraphInstanceId }, + ]; + const nodeInstanceId = deriveNodeInstanceId({ + subgraphInstanceId, + templateNodeKey: 'inspect', + }); + return { itemKey, subgraphInstanceId, scope, nodeInstanceId }; +} + +function expanded(eventId = 1, itemKey = 'A'): SubgraphExpandedEvent { + const identity = instanceIdentity(itemKey); + return { + version: 1, + type: 'SubgraphExpanded', + eventId, + sessionId, + scope: identity.scope, + expansionOwnerCheck, + graphSemanticDigest, + expansionSpecDigest, + templateDigest, + parentSubgraphInstanceId: null, + catalogClaimId, + itemKey, + subgraphInstanceId: identity.subgraphInstanceId, + nodeInstanceIdsByTemplateNode: { inspect: identity.nodeInstanceId }, + }; +} + +function itemPublished( + eventId: number, + payload: { id: string; revision: number }, + incarnation: number, + introducingCatalogClaimId = catalogClaimId +): ControllerItemClaimPublishedEvent { + const identity = instanceIdentity(payload.id); + const payloadFingerprint = deriveItemFingerprint(payload); + const claimId = deriveControllerItemClaimId({ + claim: itemClaimRef, + payloadFingerprint, + expansionSpecDigest, + catalogClaimId: introducingCatalogClaimId, + subgraphInstanceId: identity.subgraphInstanceId, + incarnation, + scope: identity.scope, + }); + return { + version: 1, + type: 'ControllerItemClaimPublished', + eventId, + sessionId, + scope: identity.scope, + expansionOwnerCheck, + expansionSpecDigest, + catalogClaimId: introducingCatalogClaimId, + itemKey: payload.id, + subgraphInstanceId: identity.subgraphInstanceId, + incarnation, + claimId, + claim: itemClaimRef, + payload, + payloadFingerprint, + parentClaimIds: [introducingCatalogClaimId], + }; +} + +function activated( + eventId: number, + item: ControllerItemClaimPublishedEvent +): NodeGenerationActivatedEvent { + const identity = instanceIdentity(item.itemKey); + const executionConfigDigest = sha256Canonical({ check: 'inspect', revision: 1 }); + const activeInputClaimIds = [item.claimId]; + const nodeGenerationId = deriveNodeGenerationId({ + nodeInstanceId: identity.nodeInstanceId, + incarnation: item.incarnation, + itemFingerprint: item.payloadFingerprint, + executionConfigDigest, + activeInputClaimIds, + }); + return { + version: 1, + type: 'NodeGenerationActivated', + eventId, + sessionId, + scope: identity.scope, + subgraphInstanceId: identity.subgraphInstanceId, + nodeInstanceId: identity.nodeInstanceId, + nodeGenerationId, + templateNodeKey: 'inspect', + checkId: 'inspect', + incarnation: item.incarnation, + itemFingerprint: item.payloadFingerprint, + executionConfigDigest, + activeInputClaimIds, + }; +} + +function successfulGeneration( + firstEventId: number, + activation: NodeGenerationActivatedEvent +): readonly [ + GeneratedAttemptStartedEvent, + GeneratedCheckScheduledEvent, + GeneratedClaimPublishedEvent, + GeneratedAttemptCompletedEvent, +] { + const attemptId = sha256Canonical({ generation: activation.nodeGenerationId, attempt: 1 }); + const started: GeneratedAttemptStartedEvent = { + version: 1, + type: 'AttemptStarted', + eventId: firstEventId, + sessionId, + scope: activation.scope, + checkId: activation.checkId, + attemptId, + fence: 1, + nodeInstanceId: activation.nodeInstanceId, + nodeGenerationId: activation.nodeGenerationId, + }; + const scheduled: GeneratedCheckScheduledEvent = { + ...started, + type: 'CheckScheduled', + eventId: firstEventId + 1, + claimIds: activation.activeInputClaimIds, + }; + const payload = { id: activation.scope[0].key, inspected: true }; + const payloadFingerprint = sha256Canonical(payload); + const claimId = sha256Canonical({ + claim: outputClaimRef, + payloadFingerprint, + producerCheckId: activation.checkId, + scope: activation.scope, + attemptId, + fence: 1, + parentClaimIds: [...activation.activeInputClaimIds].sort(), + }); + const published: GeneratedClaimPublishedEvent = { + ...started, + type: 'ClaimPublished', + eventId: firstEventId + 2, + claimId, + claim: outputClaimRef, + payload, + payloadFingerprint, + producerCheckId: activation.checkId, + parentClaimIds: activation.activeInputClaimIds, + }; + const completed: GeneratedAttemptCompletedEvent = { + ...started, + type: 'AttemptCompleted', + eventId: firstEventId + 3, + }; + return [started, scheduled, published, completed]; +} + +function managedFixture() { + const item = itemPublished(2, { id: 'A', revision: 1 }, 1); + const activation = activated(3, item); + const [attempt, scheduled] = successfulGeneration(4, activation); + const events: InstanceRuntimeEvent[] = [expanded(), item, activation, attempt, scheduled]; + const projection = replayInstanceEvents(events); + const authority = { + sessionId, + checkId: attempt.checkId, + scope: attempt.scope, + nodeInstanceId: attempt.nodeInstanceId, + nodeGenerationId: attempt.nodeGenerationId, + attemptId: attempt.attemptId, + fence: attempt.fence, + }; + const binding: ManagedRunBindingV1 = { + managedRunId: deriveManagedRunId(authority), + ...authority, + }; + return { projection, events, attempt, binding }; +} + +function managedEnvelope(binding: ManagedRunBindingV1, eventId: number) { + return { + version: 1 as const, + eventId, + sessionId: binding.sessionId, + scope: binding.scope, + binding, + }; +} + +function managedAcquired( + binding: ManagedRunBindingV1, + eventId: number +): ManagedRunAcquiredEvent { + return { ...managedEnvelope(binding, eventId), type: 'ManagedRunAcquired' }; +} + +function managedStarted( + binding: ManagedRunBindingV1, + eventId: number +): ManagedRunStartedEvent { + return { ...managedEnvelope(binding, eventId), type: 'ManagedRunStarted' }; +} + +function managedTerminated( + binding: ManagedRunBindingV1, + eventId: number, + input: + | { readonly cleanupStatus: 'clean'; readonly controllerDecision: 'completed'; readonly failureCode: null } + | { + readonly cleanupStatus: 'clean' | 'unverified'; + readonly controllerDecision: 'failed'; + readonly failureCode: ManagedRunFailureCode; + } +): ManagedRunTerminatedEvent { + return { ...managedEnvelope(binding, eventId), type: 'ManagedRunTerminated', ...input }; +} + +function managedAttemptFailed( + attempt: GeneratedAttemptStartedEvent, + eventId: number, + reason: ManagedRunFailureCode +) { + return { ...attempt, type: 'AttemptFailed' as const, eventId, reason }; +} + +function managedAttemptCompleted(attempt: GeneratedAttemptStartedEvent, eventId: number) { + return { ...attempt, type: 'AttemptCompleted' as const, eventId }; +} + +describe('Graph v2 C2 instance kernel', () => { + it('uses tagged scopes and rejects ambiguous, extra, mixed, and malformed segments', () => { + const parent = instanceIdentity('parent'); + const childId = deriveSubgraphInstanceId({ + graphSemanticDigest, + parentSubgraphInstanceId: parent.subgraphInstanceId, + expansionOwnerNodeInstanceId: parent.nodeInstanceId, + templateDigest, + itemKey: 'child', + }); + const childScope: KeyedScopePath = [ + ...parent.scope, + { + kind: 'keyed', + expansionOwnerCheck: 'nested-owner', + key: 'child', + subgraphInstanceId: childId, + }, + ]; + expect(validateTaggedScopePath([])).toEqual([]); + expect( + validateTaggedScopePath([ + { kind: 'indexed', check: 'matrix', index: 0 }, + { kind: 'indexed', check: 'nested', index: 2 }, + ]) + ).toHaveLength(2); + expect(requireKeyedScopePath(instanceIdentity().scope)).toEqual(instanceIdentity().scope); + expect(requireKeyedScopePath(childScope, childScope)).toEqual(childScope); + expectKernelError( + () => requireKeyedScopePath(childScope, [childScope[1]]), + 'INVALID_SCOPE' + ); + + for (const invalid of [ + [{ check: 'legacy-untagged', index: 0 }], + [{ kind: 'indexed', check: 'x', index: 0, extra: true }], + [{ kind: 'indexed', check: 'x', index: Number.MAX_SAFE_INTEGER + 1 }], + [ + { kind: 'indexed', check: 'x', index: 0 }, + instanceIdentity().scope[0], + ], + [...childScope, childScope[1]], + ]) { + expectKernelError(() => validateTaggedScopePath(invalid), 'INVALID_SCOPE'); + } + }); + + it('derives reorder-stable instance/node identities and canonical item keys', () => { + const aBefore = instanceIdentity('A'); + const bBefore = instanceIdentity('B'); + const reordered = [instanceIdentity('B'), instanceIdentity('A')]; + expect(reordered[1]).toEqual(aBefore); + expect(reordered[0]).toEqual(bBefore); + expect(canonicalCatalogKey(1)).toBe('1'); + expect(canonicalCatalogKey('1')).toBe('1'); + expect(canonicalCatalogKey(-0)).toBe('0'); + expectKernelError(() => canonicalCatalogKey(''), 'INVALID_ITEM_KEY'); + }); + + it('binds child identity to its exact parent and expansion-owner node', () => { + const parentA = instanceIdentity('A'); + const parentB = instanceIdentity('B'); + const childForA = deriveSubgraphInstanceId({ + graphSemanticDigest, + parentSubgraphInstanceId: parentA.subgraphInstanceId, + expansionOwnerNodeInstanceId: parentA.nodeInstanceId, + templateDigest, + itemKey: 'same-spec', + }); + const childForB = deriveSubgraphInstanceId({ + graphSemanticDigest, + parentSubgraphInstanceId: parentB.subgraphInstanceId, + expansionOwnerNodeInstanceId: parentB.nodeInstanceId, + templateDigest, + itemKey: 'same-spec', + }); + const childForDifferentOwner = deriveSubgraphInstanceId({ + graphSemanticDigest, + parentSubgraphInstanceId: parentA.subgraphInstanceId, + expansionOwnerNodeInstanceId: deriveNodeInstanceId({ + subgraphInstanceId: parentA.subgraphInstanceId, + templateNodeKey: 'different-owner', + }), + templateDigest, + itemKey: 'same-spec', + }); + + expect(childForA).not.toBe(childForB); + expect(childForA).not.toBe(childForDifferentOwner); + }); + + it('replays expansion, controller claim, activation, and bound generated lifecycle immutably', () => { + const item = itemPublished(2, { id: 'A', revision: 1 }, 1); + const activation = activated(3, item); + const lifecycle = successfulGeneration(4, activation); + const events: InstanceRuntimeEvent[] = [expanded(), item, activation, ...lifecycle]; + const live = events.reduce(reduceInstanceEvent, createInitialInstanceProjection()); + + expect(queryReadyGenerations(live)).toEqual([]); + expect(live.instancesById[item.subgraphInstanceId]).toMatchObject({ + status: 'active', + incarnation: 1, + activeItemClaimId: item.claimId, + }); + expect(live.generationsById[activation.nodeGenerationId]).toMatchObject({ + status: 'completed', + scheduled: true, + completedOutputClaimIds: [lifecycle[2].claimId], + }); + expect(live.claimsById[lifecycle[2].claimId]).toMatchObject({ + active: true, + nodeGenerationId: activation.nodeGenerationId, + parentClaimIds: [item.claimId], + }); + expect(replayInstanceEvents(events)).toEqual(live); + expect(Object.isFrozen(live)).toBe(true); + expect(Object.isFrozen(live.generationsById[activation.nodeGenerationId])).toBe(true); + expect(Object.isFrozen(live.claimsById[item.claimId].payload)).toBe(true); + }); + + it('inactivates one incarnation exactly and activates only its replacement', () => { + const firstItem = itemPublished(2, { id: 'A', revision: 1 }, 1); + const firstActivation = activated(3, firstItem); + const lifecycle = successfulGeneration(4, firstActivation); + let projection = replayInstanceEvents([expanded(), firstItem, firstActivation, ...lifecycle]); + const inactivated: NodeGenerationInactivatedEvent = { + version: 1, + type: 'NodeGenerationInactivated', + eventId: 8, + sessionId, + scope: firstActivation.scope, + subgraphInstanceId: firstActivation.subgraphInstanceId, + nodeInstanceId: firstActivation.nodeInstanceId, + nodeGenerationId: firstActivation.nodeGenerationId, + incarnation: 1, + outputClaimIds: [lifecycle[2].claimId], + reason: 'superseded', + }; + projection = reduceInstanceEvent(projection, inactivated); + const nextCatalogClaimId = sha256Canonical({ catalog: 2 }); + const secondItem = itemPublished(9, { id: 'A', revision: 2 }, 2, nextCatalogClaimId); + projection = reduceInstanceEvent(projection, secondItem); + const secondActivation = activated(10, secondItem); + projection = reduceInstanceEvent(projection, secondActivation); + + expect(firstActivation.nodeInstanceId).toBe(secondActivation.nodeInstanceId); + expect(firstActivation.nodeGenerationId).not.toBe(secondActivation.nodeGenerationId); + expect(projection.generationsById[firstActivation.nodeGenerationId].status).toBe('inactive'); + expect(projection.claimsById[firstItem.claimId].active).toBe(false); + expect(projection.claimsById[lifecycle[2].claimId].active).toBe(false); + expect(queryReadyGenerations(projection).map(value => value.nodeGenerationId)).toEqual([ + secondActivation.nodeGenerationId, + ]); + }); + + it('tombstones without deleting history and fails closed on key re-add', () => { + const item = itemPublished(2, { id: 'A', revision: 1 }, 1); + const activation = activated(3, item); + const lifecycle = successfulGeneration(4, activation); + let projection = replayInstanceEvents([expanded(), item, activation, ...lifecycle]); + const tombstone: SubgraphTombstonedEvent = { + version: 1, + type: 'SubgraphTombstoned', + eventId: 8, + sessionId, + scope: activation.scope, + expansionOwnerCheck, + sourceCatalogClaimId: sha256Canonical({ catalog: 'remove' }), + itemKey: 'A', + subgraphInstanceId: activation.subgraphInstanceId, + lastIncarnation: 1, + nodeGenerationIds: [activation.nodeGenerationId], + outputClaimIds: [lifecycle[2].claimId], + }; + projection = reduceInstanceEvent(projection, tombstone); + expect(projection.instancesById[activation.subgraphInstanceId].status).toBe('tombstoned'); + expect(projection.generationsById[activation.nodeGenerationId].status).toBe('inactive'); + expect(projection.claimsById[lifecycle[2].claimId].active).toBe(false); + expectKernelError(() => reduceInstanceEvent(projection, expanded(9)), 'TOMBSTONED_KEY_READD_UNSUPPORTED'); + }); + + it('keeps catalog requests FIFO and behind every ready or running generation', () => { + const item = itemPublished(2, { id: 'A', revision: 1 }, 1); + const activation = activated(3, item); + let projection = replayInstanceEvents([expanded(), item, activation]); + const requestId = deriveCatalogRequestId({ + sessionId, + expansionOwnerCheck, + ordinal: 1, + }); + projection = reduceInstanceEvent(projection, { + version: 1, + type: 'CatalogReconciliationRequested', + eventId: 4, + sessionId, + scope: [], + requestId, + requestOrdinal: 1, + expansionOwnerCheck, + status: 'pending', + }); + const requestStart = { + version: 1 as const, + type: 'AttemptStarted' as const, + eventId: 5, + sessionId, + scope: [] as const, + requestId, + checkId: expansionOwnerCheck, + attemptId: sha256Canonical({ requestId, attempt: 1 }), + fence: 2, + }; + expectKernelError( + () => reduceInstanceEvent(projection, requestStart), + 'GENERATED_WORK_PRECEDES_REQUEST' + ); + expect(projection.requestsById[requestId].status).toBe('pending'); + }); + + it('does not start a later FIFO request while its predecessor is running', () => { + let projection = createInitialInstanceProjection(); + const ids = [1, 2].map(ordinal => deriveCatalogRequestId({ + sessionId, expansionOwnerCheck, ordinal, + })); + for (let index = 0; index < ids.length; index++) { + projection = reduceInstanceEvent(projection, { + version: 1, type: 'CatalogReconciliationRequested', eventId: index + 1, + sessionId, scope: [], requestId: ids[index], requestOrdinal: index + 1, + expansionOwnerCheck, status: 'pending', + }); + } + projection = reduceInstanceEvent(projection, { + version: 1, type: 'AttemptStarted', eventId: 3, sessionId, scope: [], + requestId: ids[0], checkId: expansionOwnerCheck, + attemptId: sha256Canonical({ requestId: ids[0], attempt: 1 }), fence: 1, + }); + expectKernelError(() => reduceInstanceEvent(projection, { + version: 1, type: 'AttemptStarted', eventId: 4, sessionId, scope: [], + requestId: ids[1], checkId: expansionOwnerCheck, + attemptId: sha256Canonical({ requestId: ids[1], attempt: 1 }), fence: 2, + }), 'GENERATED_WORK_PRECEDES_REQUEST'); + }); + + it('rejects stale, cross-instance, and caller-forged lifecycle bindings without mutation', () => { + const item = itemPublished(2, { id: 'A', revision: 1 }, 1); + const activation = activated(3, item); + const projection = replayInstanceEvents([expanded(), item, activation]); + const before: InstanceProjection = projection; + const [started, scheduled] = successfulGeneration(4, activation); + expectKernelError( + () => reduceInstanceEvent(projection, { ...started, nodeInstanceId: sha256Canonical('forged') }), + 'INVALID_GENERATION_BINDING' + ); + expectKernelError( + () => reduceInstanceEvent(reduceInstanceEvent(projection, started), { ...scheduled, claimIds: [] }), + 'INVALID_SCHEDULED_CLAIMS' + ); + expect(projection).toBe(before); + expect(projection.generationsById[activation.nodeGenerationId].status).toBe('ready'); + }); +}); + +describe('Graph v2 C3 managed run lifecycle kernel', () => { + it('atomically accepts one acquisition failure followed by its exact failed attempt', () => { + const fixture = managedFixture(); + const acquisitionFailed: ManagedRunAcquisitionFailedEvent = { + ...managedEnvelope(fixture.binding, 6), + type: 'ManagedRunAcquisitionFailed', + failureCode: 'MANAGED_START_FAILED', + }; + const attemptFailed = managedAttemptFailed(fixture.attempt, 7, 'MANAGED_START_FAILED'); + + expectKernelError( + () => reduceInstanceEventBatch(fixture.projection, [acquisitionFailed]), + 'INVALID_MANAGED_BATCH' + ); + expect(fixture.projection.lastEventId).toBe(5); + + const failed = reduceInstanceEventBatch(fixture.projection, [acquisitionFailed, attemptFailed]); + expect(failed.managedRunsByAttemptId[fixture.binding.attemptId]).toEqual({ + binding: fixture.binding, + status: 'acquisition_failed', + controllerDecision: 'failed', + failureCode: 'MANAGED_START_FAILED', + }); + expect(failed.generationsById[fixture.binding.nodeGenerationId]).toMatchObject({ + status: 'failed', + reason: 'MANAGED_START_FAILED', + }); + expectKernelError( + () => reduceInstanceEvent(failed, managedAcquired(fixture.binding, 8)), + 'INVALID_MANAGED_BINDING' + ); + }); + + it('accepts acquired, started, and one clean controller-completed terminal batch', () => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const started = managedStarted(fixture.binding, 7); + const beforeTerminal = replayInstanceEvents([...fixture.events, acquired, started]); + const terminated = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'clean', + controllerDecision: 'completed', + failureCode: null, + }); + const completed = managedAttemptCompleted(fixture.attempt, 9); + + expectKernelError( + () => reduceInstanceEventBatch(beforeTerminal, [completed]), + 'INVALID_MANAGED_BATCH' + ); + const final = reduceInstanceEventBatch(beforeTerminal, [terminated, completed]); + expect(final.managedRunsByAttemptId[fixture.binding.attemptId]).toEqual({ + binding: fixture.binding, + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'completed', + }); + expect(final.generationsById[fixture.binding.nodeGenerationId].status).toBe('completed'); + expectKernelError( + () => reduceInstanceEvent(final, { ...terminated, eventId: 10 }), + 'INVALID_MANAGED_BINDING' + ); + }); + + it.each([ + 'MANAGED_FATAL_SUMMARY', + 'MANAGED_FAIL_IF', + 'MANAGED_HALT_EXECUTION', + 'MANAGED_CLAIM_VALIDATION_FAILED', + ])('keeps clean cleanup separate from controller failure %s', failureCode => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const beforeTerminal = reduceInstanceEvent(fixture.projection, acquired); + const terminated = managedTerminated(fixture.binding, 7, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode, + }); + const attemptFailed = managedAttemptFailed(fixture.attempt, 8, failureCode); + const final = reduceInstanceEventBatch(beforeTerminal, [terminated, attemptFailed]); + + expect(final.managedRunsByAttemptId[fixture.binding.attemptId]).toMatchObject({ + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode, + }); + expect(final.generationsById[fixture.binding.nodeGenerationId]).toMatchObject({ + status: 'failed', + reason: failureCode, + }); + }); + + it('records one deadline cancellation with unverified failed cleanup', () => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const cancelRequested: ManagedRunCancelRequestedEvent = { + ...managedEnvelope(fixture.binding, 7), + type: 'ManagedRunCancelRequested', + reason: 'deadline', + }; + const terminated = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'unverified', + controllerDecision: 'failed', + failureCode: 'MANAGED_CLOSE_FAILED', + }); + const attemptFailed = managedAttemptFailed(fixture.attempt, 9, 'MANAGED_CLOSE_FAILED'); + const live = reduceInstanceEventBatch( + replayInstanceEvents([...fixture.events, acquired, cancelRequested]), + [terminated, attemptFailed] + ); + + expect(live.managedRunsByAttemptId[fixture.binding.attemptId]).toMatchObject({ + status: 'terminated', + cleanupStatus: 'unverified', + controllerDecision: 'failed', + failureCode: 'MANAGED_CLOSE_FAILED', + }); + expectKernelError( + () => reduceInstanceEvent(live, { ...cancelRequested, eventId: 10 }), + 'INVALID_MANAGED_BINDING' + ); + }); + + it('accepts one valid clean deadline terminal after the current-fence cancel fact', () => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const cancelRequested: ManagedRunCancelRequestedEvent = { + ...managedEnvelope(fixture.binding, 7), + type: 'ManagedRunCancelRequested', + reason: 'deadline', + }; + const terminated = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + const attemptFailed = managedAttemptFailed( + fixture.attempt, + 9, + 'MANAGED_DEADLINE_EXCEEDED' + ); + const cancelled = replayInstanceEvents([...fixture.events, acquired, cancelRequested]); + const final = reduceInstanceEventBatch(cancelled, [terminated, attemptFailed]); + + expect(final.managedRunsByAttemptId[fixture.binding.attemptId]).toEqual({ + binding: fixture.binding, + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }); + expect(final.generationsById[fixture.binding.nodeGenerationId]).toMatchObject({ + status: 'failed', + reason: 'MANAGED_DEADLINE_EXCEEDED', + }); + }); + + it('rejects a late managed terminal after atomic acquisition failure', () => { + const fixture = managedFixture(); + const acquisitionFailed: ManagedRunAcquisitionFailedEvent = { + ...managedEnvelope(fixture.binding, 6), + type: 'ManagedRunAcquisitionFailed', + failureCode: 'MANAGED_START_FAILED', + }; + const failed = reduceInstanceEventBatch(fixture.projection, [ + acquisitionFailed, + managedAttemptFailed(fixture.attempt, 7, 'MANAGED_START_FAILED'), + ]); + const lateTerminal = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_POST_PROVIDER_FAILED', + }); + + expectAnyKernelError(() => reduceInstanceEvent(failed, lateTerminal)); + expect(failed.managedRunsByAttemptId[fixture.binding.attemptId]).toEqual({ + binding: fixture.binding, + status: 'acquisition_failed', + controllerDecision: 'failed', + failureCode: 'MANAGED_START_FAILED', + }); + }); + + it('canonically replays every managed lifecycle shape without invoking collaborators', () => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const acquiredProjection = reduceInstanceEvent(fixture.projection, acquired); + const started = managedStarted(fixture.binding, 7); + const startedProjection = reduceInstanceEvent(acquiredProjection, started); + const cancelRequested: ManagedRunCancelRequestedEvent = { + ...managedEnvelope(fixture.binding, 7), + type: 'ManagedRunCancelRequested', + reason: 'deadline', + }; + const cancelledProjection = reduceInstanceEvent(acquiredProjection, cancelRequested); + const acquisitionFailed: ManagedRunAcquisitionFailedEvent = { + ...managedEnvelope(fixture.binding, 6), + type: 'ManagedRunAcquisitionFailed', + failureCode: 'MANAGED_START_FAILED', + }; + const cleanCompleted = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'clean', + controllerDecision: 'completed', + failureCode: null, + }); + const cleanFailed = managedTerminated(fixture.binding, 7, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }); + const unverifiedFailed = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'unverified', + controllerDecision: 'failed', + failureCode: 'MANAGED_CLOSE_FAILED', + }); + const acquisitionAttemptFailed = managedAttemptFailed( + fixture.attempt, + 7, + 'MANAGED_START_FAILED' + ); + const completedAttempt = managedAttemptCompleted(fixture.attempt, 9); + const cleanFailedAttempt = managedAttemptFailed( + fixture.attempt, + 8, + 'MANAGED_FATAL_SUMMARY' + ); + const unverifiedFailedAttempt = managedAttemptFailed( + fixture.attempt, + 9, + 'MANAGED_CLOSE_FAILED' + ); + + type ReplayRow = { + readonly name: string; + readonly events: readonly InstanceRuntimeEvent[]; + readonly live: InstanceProjection; + readonly expectedManaged: InstanceProjection['managedRunsByAttemptId'][string]; + readonly managedTerminalCount: number; + readonly attemptTerminalCount: number; + }; + const rows: readonly ReplayRow[] = [ + { + name: 'acquisition-failed', + events: [...fixture.events, acquisitionFailed, acquisitionAttemptFailed], + live: reduceInstanceEventBatch(fixture.projection, [ + acquisitionFailed, + acquisitionAttemptFailed, + ]), + expectedManaged: { + binding: fixture.binding, + status: 'acquisition_failed', + controllerDecision: 'failed', + failureCode: 'MANAGED_START_FAILED', + }, + managedTerminalCount: 1, + attemptTerminalCount: 1, + }, + { + name: 'acquired', + events: [...fixture.events, acquired], + live: acquiredProjection, + expectedManaged: { binding: fixture.binding, status: 'acquired' }, + managedTerminalCount: 0, + attemptTerminalCount: 0, + }, + { + name: 'cancel-requested', + events: [...fixture.events, acquired, cancelRequested], + live: cancelledProjection, + expectedManaged: { binding: fixture.binding, status: 'cancel_requested' }, + managedTerminalCount: 0, + attemptTerminalCount: 0, + }, + { + name: 'clean-completed', + events: [...fixture.events, acquired, started, cleanCompleted, completedAttempt], + live: reduceInstanceEventBatch(startedProjection, [cleanCompleted, completedAttempt]), + expectedManaged: { + binding: fixture.binding, + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'completed', + }, + managedTerminalCount: 1, + attemptTerminalCount: 1, + }, + { + name: 'clean-failed', + events: [...fixture.events, acquired, cleanFailed, cleanFailedAttempt], + live: reduceInstanceEventBatch(acquiredProjection, [cleanFailed, cleanFailedAttempt]), + expectedManaged: { + binding: fixture.binding, + status: 'terminated', + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }, + managedTerminalCount: 1, + attemptTerminalCount: 1, + }, + { + name: 'unverified-failed', + events: [ + ...fixture.events, + acquired, + cancelRequested, + unverifiedFailed, + unverifiedFailedAttempt, + ], + live: reduceInstanceEventBatch(cancelledProjection, [ + unverifiedFailed, + unverifiedFailedAttempt, + ]), + expectedManaged: { + binding: fixture.binding, + status: 'terminated', + cleanupStatus: 'unverified', + controllerDecision: 'failed', + failureCode: 'MANAGED_CLOSE_FAILED', + }, + managedTerminalCount: 1, + attemptTerminalCount: 1, + }, + ]; + expect(rows.map(row => row.name)).toEqual([ + 'acquisition-failed', + 'acquired', + 'cancel-requested', + 'clean-completed', + 'clean-failed', + 'unverified-failed', + ]); + + for (const row of rows) { + const parsed = deepFreeze( + JSON.parse(canonicalJson(row.events)) + ); + expectRecursivelyFrozen(parsed); + expect(canonicalJson(parsed)).toBe(canonicalJson(row.events)); + + const replayed = replayInstanceEvents(parsed); + expect(replayed).toEqual(row.live); + expectRecursivelyFrozen(replayed); + expect(replayed.managedRunsByAttemptId).toEqual(row.live.managedRunsByAttemptId); + expect(row.live.managedRunsByAttemptId[fixture.binding.attemptId]).toEqual( + row.expectedManaged + ); + expect(replayed.managedRunsByAttemptId[fixture.binding.attemptId]).toEqual( + row.expectedManaged + ); + expect(replayed.managedRunsByAttemptId[fixture.binding.attemptId].binding).toEqual( + fixture.binding + ); + + const managedTerminal = (event: InstanceRuntimeEvent) => + event.type === 'ManagedRunAcquisitionFailed' || event.type === 'ManagedRunTerminated'; + const attemptTerminal = (event: InstanceRuntimeEvent) => + event.type === 'AttemptCompleted' || event.type === 'AttemptFailed'; + expect(row.events.filter(managedTerminal)).toHaveLength(row.managedTerminalCount); + expect(parsed.filter(managedTerminal)).toHaveLength(row.managedTerminalCount); + expect(row.events.filter(attemptTerminal)).toHaveLength(row.attemptTerminalCount); + expect(parsed.filter(attemptTerminal)).toHaveLength(row.attemptTerminalCount); + expect( + Object.values(replayed.managedRunsByAttemptId).filter(run => + run.status === 'acquisition_failed' || run.status === 'terminated' + ) + ).toHaveLength(row.managedTerminalCount); + + const readyBeforeSerialization = queryReadyGenerations(row.live).map( + generation => generation.nodeGenerationId + ); + const readyAfterReplay = queryReadyGenerations(replayed).map( + generation => generation.nodeGenerationId + ); + expect(readyAfterReplay).toEqual(readyBeforeSerialization); + expect(readyAfterReplay).toEqual([]); + } + + }); + + it('rejects replayed managed terminal prefixes and non-adjacent terminal pairs', () => { + const fixture = managedFixture(); + const acquisitionFailed: ManagedRunAcquisitionFailedEvent = { + ...managedEnvelope(fixture.binding, 6), + type: 'ManagedRunAcquisitionFailed', + failureCode: 'MANAGED_START_FAILED', + }; + const attemptFailed = managedAttemptFailed(fixture.attempt, 7, 'MANAGED_START_FAILED'); + + expectKernelError( + () => replayInstanceEvents([...fixture.events, acquisitionFailed]), + 'INVALID_MANAGED_BATCH' + ); + expectKernelError( + () => + replayInstanceEvents([ + ...fixture.events, + acquisitionFailed, + managedAcquired(fixture.binding, 7), + { ...attemptFailed, eventId: 8 }, + ]), + 'INVALID_MANAGED_BATCH' + ); + + const acquired = managedAcquired(fixture.binding, 6); + const terminated = managedTerminated(fixture.binding, 7, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }); + expectKernelError( + () => replayInstanceEvents([...fixture.events, acquired, terminated]), + 'INVALID_MANAGED_BATCH' + ); + }); + + it('enforces cleanup, decision, failure-code, and cancel-state coherence', () => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const acquiredProjection = reduceInstanceEvent(fixture.projection, acquired); + const cancelRequested: ManagedRunCancelRequestedEvent = { + ...managedEnvelope(fixture.binding, 7), + type: 'ManagedRunCancelRequested', + reason: 'deadline', + }; + const cancelledProjection = reduceInstanceEvent(acquiredProjection, cancelRequested); + + const invalidRows: Array<{ + readonly projection: InstanceProjection; + readonly eventId: number; + readonly cleanupStatus: unknown; + readonly controllerDecision: unknown; + readonly failureCode: ManagedRunFailureCode; + }> = [ + { + projection: acquiredProjection, + eventId: 7, + cleanupStatus: 'dirty', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }, + { + projection: acquiredProjection, + eventId: 7, + cleanupStatus: 'clean', + controllerDecision: 'unknown', + failureCode: 'MANAGED_FATAL_SUMMARY', + }, + { + projection: acquiredProjection, + eventId: 7, + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_CLOSE_FAILED', + }, + { + projection: acquiredProjection, + eventId: 7, + cleanupStatus: 'unverified', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }, + { + projection: acquiredProjection, + eventId: 7, + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_DEADLINE_EXCEEDED', + }, + { + projection: cancelledProjection, + eventId: 8, + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }, + { + projection: acquiredProjection, + eventId: 7, + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_START_FAILED', + }, + ]; + + for (const row of invalidRows) { + const terminated = { + ...managedEnvelope(fixture.binding, row.eventId), + type: 'ManagedRunTerminated', + cleanupStatus: row.cleanupStatus, + controllerDecision: row.controllerDecision, + failureCode: row.failureCode, + } as unknown as ManagedRunTerminatedEvent; + const failed = managedAttemptFailed( + fixture.attempt, + row.eventId + 1, + row.failureCode + ); + if (row.controllerDecision === 'unknown') { + expectKernelError( + () => reduceInstanceEvent(row.projection, terminated), + 'INVALID_MANAGED_TERMINAL' + ); + } else { + expectKernelError( + () => reduceInstanceEventBatch(row.projection, [terminated, failed]), + 'INVALID_MANAGED_TERMINAL' + ); + } + } + + const ordinaryCloseFailure = managedTerminated(fixture.binding, 7, { + cleanupStatus: 'unverified', + controllerDecision: 'failed', + failureCode: 'MANAGED_CLOSE_FAILED', + }); + expect( + reduceInstanceEventBatch(acquiredProjection, [ + ordinaryCloseFailure, + managedAttemptFailed(fixture.attempt, 8, 'MANAGED_CLOSE_FAILED'), + ]).managedRunsByAttemptId[fixture.binding.attemptId] + ).toMatchObject({ + status: 'terminated', + cleanupStatus: 'unverified', + failureCode: 'MANAGED_CLOSE_FAILED', + }); + }); + + it('compares all eight binding fields including deep exact scope without partial mutation', () => { + const fixture = managedFixture(); + const otherScope = instanceIdentity('B').scope; + const mutations: Array<{ + readonly name: string; + readonly values: Partial; + readonly preserveRunId?: boolean; + }> = [ + { name: 'session', values: { sessionId: 'wrong-session' } }, + { name: 'check', values: { checkId: 'wrong-check' } }, + { name: 'scope', values: { scope: otherScope } }, + { name: 'run', values: { managedRunId: sha256Canonical('wrong-run') }, preserveRunId: true }, + { name: 'instance', values: { nodeInstanceId: sha256Canonical('wrong-instance') } }, + { name: 'generation', values: { nodeGenerationId: sha256Canonical('wrong-generation') } }, + { name: 'attempt', values: { attemptId: sha256Canonical('wrong-attempt') } }, + { name: 'fence', values: { fence: fixture.binding.fence + 1 } }, + ]; + + for (const mutation of mutations) { + const changed = { ...fixture.binding, ...mutation.values }; + const authority = { + sessionId: changed.sessionId, + checkId: changed.checkId, + scope: changed.scope, + nodeInstanceId: changed.nodeInstanceId, + nodeGenerationId: changed.nodeGenerationId, + attemptId: changed.attemptId, + fence: changed.fence, + }; + const binding: ManagedRunBindingV1 = { + ...changed, + managedRunId: mutation.preserveRunId + ? changed.managedRunId + : deriveManagedRunId(authority), + }; + const event = managedAcquired(binding, 6); + expectAnyKernelError(() => reduceInstanceEvent(fixture.projection, event)); + expect(fixture.projection.lastEventId).toBe(5); + expect(fixture.projection.managedRunsByAttemptId).toEqual({}); + } + }); + + it('associates an attempt terminal with the complete lifecycle binding', () => { + const fixture = managedFixture(); + const acquiredProjection = reduceInstanceEvent( + fixture.projection, + managedAcquired(fixture.binding, 6) + ); + const terminated = managedTerminated(fixture.binding, 7, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_FATAL_SUMMARY', + }); + const mutations: Array> = [ + { sessionId: 'wrong-session' }, + { checkId: 'wrong-check' }, + { scope: instanceIdentity('B').scope }, + { nodeInstanceId: sha256Canonical('wrong-instance') }, + { nodeGenerationId: sha256Canonical('wrong-generation') }, + { attemptId: sha256Canonical('wrong-attempt') }, + { fence: fixture.attempt.fence + 1 }, + ]; + + for (const mutation of mutations) { + const failed = { + ...managedAttemptFailed(fixture.attempt, 8, 'MANAGED_FATAL_SUMMARY'), + ...mutation, + }; + expectKernelError( + () => reduceInstanceEventBatch(acquiredProjection, [terminated, failed]), + 'INVALID_MANAGED_BATCH' + ); + expect(acquiredProjection.lastEventId).toBe(6); + expect(acquiredProjection.managedRunsByAttemptId[fixture.binding.attemptId].status).toBe( + 'acquired' + ); + } + }); + + it('rejects duplicate, late, wrong-code, and plain acquired-attempt terminal events', () => { + const fixture = managedFixture(); + const acquired = managedAcquired(fixture.binding, 6); + const acquiredProjection = reduceInstanceEvent(fixture.projection, acquired); + expectKernelError( + () => reduceInstanceEvent(acquiredProjection, { ...acquired, eventId: 7 }), + 'MANAGED_RUN_ALREADY_ACQUIRED' + ); + expectKernelError( + () => + reduceInstanceEvent( + acquiredProjection, + managedAttemptFailed(fixture.attempt, 7, 'MANAGED_POST_PROVIDER_FAILED') + ), + 'MANAGED_TERMINAL_REQUIRED' + ); + + const started = managedStarted(fixture.binding, 7); + const startedProjection = reduceInstanceEvent(acquiredProjection, started); + expectKernelError( + () => reduceInstanceEvent(startedProjection, { ...started, eventId: 8 }), + 'INVALID_MANAGED_TRANSITION' + ); + const terminated = managedTerminated(fixture.binding, 8, { + cleanupStatus: 'clean', + controllerDecision: 'failed', + failureCode: 'MANAGED_FAIL_IF', + }); + expectKernelError( + () => + reduceInstanceEventBatch(startedProjection, [ + terminated, + managedAttemptFailed(fixture.attempt, 9, 'MANAGED_FATAL_SUMMARY'), + ]), + 'INVALID_MANAGED_BATCH' + ); + expect(startedProjection.lastEventId).toBe(7); + }); +}); diff --git a/tests/unit/state-machine/graph/instance-plan.test.ts b/tests/unit/state-machine/graph/instance-plan.test.ts new file mode 100644 index 000000000..749df61da --- /dev/null +++ b/tests/unit/state-machine/graph/instance-plan.test.ts @@ -0,0 +1,340 @@ +import { + compileJsonPointer, + InstancePlanError, + PROOF_ADMIT_PROVIDER_TYPE, + PROOF_ADMITTED_RECEIPT_CLAIM, + PROOF_CANDIDATE_CLAIM, + qualifiedNestedExpansionOwner, + resolveJsonPointer, +} from '../../../../src/state-machine/graph/instance-plan'; +import { compileClaimPlan } from '../../../../src/state-machine/graph/claim-plan'; + +function config(): any { + return { + version: '1.0', + claim_types: { + 'component.catalog@1': { + schema: { + type: 'object', + required: ['components'], + properties: { components: { type: 'array' } }, + }, + }, + 'component.item@1': { + schema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + 'component.onboarded@1': { + schema: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, + subgraphs: { + 'onboard-component': { + input: { name: 'component', claim: 'component.item@1' }, + checks: { + inspect: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: 'component.onboarded@1', from: 'output' }], + }, + summarize: { + type: 'noop', + consumes: [{ claim: 'component.onboarded@1', as: 'inspected' }], + }, + }, + }, + }, + checks: { + discover: { + type: 'noop', + emits: [{ claim: 'component.catalog@1', from: 'output' }], + expand: { + claim: 'component.catalog@1', + template: 'onboard-component', + items_pointer: '/components', + key_pointer: '/id', + item_claim: 'component.item@1', + }, + }, + }, + }; +} + +function proofAdmissionConfig(): any { + const value = config(); + Object.assign(value.claim_types, { + [PROOF_CANDIDATE_CLAIM]: { schema: { type: 'object' } }, + [PROOF_ADMITTED_RECEIPT_CLAIM]: { schema: { type: 'object' } }, + }); + value.subgraphs['onboard-component'].checks = { + inspect: { + type: 'noop', + consumes: [{ claim: 'component.item@1', as: 'component' }], + emits: [{ claim: PROOF_CANDIDATE_CLAIM, from: 'output' }], + }, + proof_admit: { + type: PROOF_ADMIT_PROVIDER_TYPE, + consumes: [{ claim: PROOF_CANDIDATE_CLAIM, as: 'candidate' }], + emits: [{ claim: PROOF_ADMITTED_RECEIPT_CLAIM, from: 'output' }], + }, + verify: { + type: 'noop', + consumes: [ + { claim: PROOF_CANDIDATE_CLAIM, as: 'candidate' }, + { claim: PROOF_ADMITTED_RECEIPT_CLAIM, as: 'receipt' }, + ], + }, + }; + return value; +} + +describe('Graph v2 C2 expansion plan', () => { + it('compiles the exact reserved inspect -> proof_admit -> verify profile', () => { + const plan = compileClaimPlan(proofAdmissionConfig()).expansionPlan; + const template = plan.byOwner.discover.template; + expect(template.templateNodeKeys).toEqual(['inspect', 'proof_admit', 'verify']); + expect(template.topology).toEqual(template.templateNodeKeys); + expect(template.emitterByClaim).toMatchObject({ + [PROOF_CANDIDATE_CLAIM]: 'inspect', [PROOF_ADMITTED_RECEIPT_CLAIM]: 'proof_admit', + }); + }); + + it.each([ + ['type without refs', (value: any) => { + const checks = value.subgraphs['onboard-component'].checks; + delete checks.inspect.emits; + checks.proof_admit.consumes = [{ claim: 'component.item@1', as: 'component' }]; + checks.proof_admit.emits = [{ claim: 'component.onboarded@1', from: 'output' }]; + checks.verify.consumes = [{ claim: 'component.item@1', as: 'component' }]; + }], + ['wrong key', (value: any) => { + const checks = value.subgraphs['onboard-component'].checks; + checks.admit = checks.proof_admit; + delete checks.proof_admit; + }], + ['alternate claims', (value: any) => { + const checks = value.subgraphs['onboard-component'].checks; + value.claim_types['fixture.alternate@1'] = { schema: { type: 'object' } }; + checks.inspect.emits.push({ claim: 'fixture.alternate@1', from: 'output' }); + checks.proof_admit.consumes[0].claim = 'fixture.alternate@1'; + }], + ['extra proof-admit use', (value: any) => { + value.subgraphs['onboard-component'].checks.extra = { type: PROOF_ADMIT_PROVIDER_TYPE }; + }], + ])('rejects reserved profile with %s before provider lookup', (_name, mutate) => { + const value = proofAdmissionConfig(); + mutate(value); + try { + compileClaimPlan(value); + throw new Error('expected reserved-profile rejection'); + } catch (error) { + expect((error as InstancePlanError).code).toBe('RESERVED_PROOF_ADMISSION_PROFILE'); + } + }); + + it.each(['inspect', 'proof_admit', 'verify'])('rejects check.expand on reserved node %s', nodeKey => { + const value = proofAdmissionConfig(); + value.subgraphs['onboard-component'].checks[nodeKey].expand = {}; + expect(() => compileClaimPlan(value)).toThrow('cannot use check.expand'); + }); + + it('compiles exact immutable bindings, topology, pointers, and semantic digests', () => { + const authored = config(); + const before = JSON.parse(JSON.stringify(authored)); + const claimPlan = compileClaimPlan(authored); + const plan = claimPlan.expansionPlan; + const expansion = plan.byOwner.discover; + const template = expansion.template; + + expect(plan.active).toBe(true); + expect(plan.graphSemanticDigest).toMatch(/^[0-9a-f]{64}$/); + expect(expansion.catalogClaimRef).toBe('component.catalog@1'); + expect(expansion.itemClaimRef).toBe('component.item@1'); + expect(expansion.itemsPointer).toEqual({ source: '/components', tokens: ['components'] }); + expect(expansion.keyPointer).toEqual({ source: '/id', tokens: ['id'] }); + expect(expansion.expansionSpecDigest).toMatch(/^[0-9a-f]{64}$/); + expect(template.templateDigest).toMatch(/^[0-9a-f]{64}$/); + expect(template.topology).toEqual(['inspect', 'summarize']); + expect(template.reverseTopology).toEqual(['summarize', 'inspect']); + expect(template.sourceNodeKeys).toEqual(['inspect']); + expect(template.nodesByKey.summarize.dependencyNodeKeys).toEqual(['inspect']); + expect(template.nodesByKey.inspect.consumptions).toEqual([ + { claim: 'component.item@1', cardinality: 'one', as: 'component' }, + ]); + expect(template.nodesByKey.inspect.executionConfigDigest).toMatch(/^[0-9a-f]{64}$/); + expect(authored).toEqual(before); + expect(Object.isFrozen(plan)).toBe(true); + expect(Object.isFrozen(expansion)).toBe(true); + expect(Object.isFrozen(template.nodesByKey.inspect.check)).toBe(true); + expect(Object.isFrozen(expansion.itemsPointer.tokens)).toBe(true); + + authored.subgraphs['onboard-component'].checks.inspect.timeout = 99; + expect(template.nodesByKey.inspect.check.timeout).toBeUndefined(); + }); + + it('keeps digests stable across authored map reordering and changes execution digest on semantics', () => { + const first = compileClaimPlan(config()).expansionPlan; + const reordered = config(); + reordered.claim_types = Object.fromEntries(Object.entries(reordered.claim_types).reverse()); + reordered.subgraphs['onboard-component'].checks = { + summarize: reordered.subgraphs['onboard-component'].checks.summarize, + inspect: reordered.subgraphs['onboard-component'].checks.inspect, + }; + const second = compileClaimPlan(reordered).expansionPlan; + expect(second.graphSemanticDigest).toBe(first.graphSemanticDigest); + expect(second.byOwner.discover.templateDigest).toBe(first.byOwner.discover.templateDigest); + + const changed = config(); + changed.subgraphs['onboard-component'].checks.inspect.timeout = 1234; + const third = compileClaimPlan(changed).expansionPlan; + expect(third.byOwner.discover.template.nodesByKey.inspect.executionConfigDigest).not.toBe( + first.byOwner.discover.template.nodesByKey.inspect.executionConfigDigest + ); + expect(third.graphSemanticDigest).not.toBe(first.graphSemanticDigest); + }); + + it('compiles one parent-template-qualified depth-two expansion owner', () => { + const value = config(); + value.claim_types['spec.catalog@1'] = { + schema: { type: 'object', required: ['specs'], properties: { specs: { type: 'array' } } }, + }; + value.claim_types['spec.item@1'] = { + schema: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } }, + }; + value.subgraphs['review-spec'] = { + input: { name: 'spec', claim: 'spec.item@1' }, + checks: { + review: { + type: 'noop', + consumes: [{ claim: 'spec.item@1', as: 'spec' }], + }, + }, + }; + const inspect = value.subgraphs['onboard-component'].checks.inspect; + inspect.emits.push({ claim: 'spec.catalog@1', from: 'output' }); + inspect.expand = { + claim: 'spec.catalog@1', + template: 'review-spec', + items_pointer: '/specs', + key_pointer: '/id', + item_claim: 'spec.item@1', + }; + + const plan = compileClaimPlan(value).expansionPlan; + const owner = qualifiedNestedExpansionOwner('onboard-component', 'inspect'); + expect(plan.byNestedOwner[owner]).toMatchObject({ + expansionOwnerCheck: owner, + depth: 2, + parentTemplateName: 'onboard-component', + parentTemplateNodeKey: 'inspect', + catalogClaimRef: 'spec.catalog@1', + itemClaimRef: 'spec.item@1', + templateName: 'review-spec', + }); + expect(plan.byOwner.discover.depth).toBe(1); + expect(plan.byNestedOwner[owner].expansionSpecDigest).toMatch(/^[0-9a-f]{64}$/); + }); + + it('strictly compiles and resolves RFC 6901 pointers', () => { + const pointer = compileJsonPointer('/a~1b/~0key/0', 'fixture'); + expect(pointer.tokens).toEqual(['a/b', '~key', '0']); + expect(resolveJsonPointer({ 'a/b': { '~key': ['value'] } }, pointer)).toBe('value'); + expect(() => compileJsonPointer('components[0]', 'fixture')).toThrow(InstancePlanError); + expect(() => compileJsonPointer('/bad~2escape', 'fixture')).toThrow('invalid RFC 6901 escape'); + expect(() => resolveJsonPointer({ list: [] }, compileJsonPointer('/list/00', 'fixture'))).toThrow( + 'does not resolve exactly' + ); + }); + + it.each([ + { + name: 'missing subgraphs', + mutate: (value: any) => delete value.subgraphs, + code: 'INCOMPLETE_EXPANSION_CONFIG', + }, + { + name: 'unknown template', + mutate: (value: any) => (value.checks.discover.expand.template = 'missing'), + code: 'UNKNOWN_SUBGRAPH_TEMPLATE', + }, + { + name: 'item/template claim mismatch', + mutate: (value: any) => + (value.checks.discover.expand.item_claim = 'component.onboarded@1'), + code: 'ITEM_CLAIM_MISMATCH', + }, + { + name: 'recursive depth-three expansion', + mutate: (value: any) => { + value.subgraphs['onboard-component'].checks.inspect.emits.push({ + claim: 'component.catalog@1', + from: 'output', + }); + value.subgraphs['onboard-component'].checks.inspect.expand = { + ...value.checks.discover.expand, + template: 'onboard-component', + }; + }, + code: 'NESTED_EXPANSION_DEPTH_EXCEEDED', + }, + { + name: 'template routing', + mutate: (value: any) => + (value.subgraphs['onboard-component'].checks.inspect.on_success = { run: ['summarize'] }), + code: 'UNSUPPORTED_TEMPLATE_EXECUTION', + }, + { + name: 'unknown static dependency', + mutate: (value: any) => + (value.subgraphs['onboard-component'].checks.inspect.depends_on = 'missing'), + code: 'UNKNOWN_TEMPLATE_CHECK', + }, + { + name: 'template cycle', + mutate: (value: any) => + (value.subgraphs['onboard-component'].checks.inspect.depends_on = 'summarize'), + code: 'TEMPLATE_CYCLE', + }, + { + name: 'controller claim forgery', + mutate: (value: any) => + value.subgraphs['onboard-component'].checks.inspect.emits.push({ + claim: 'component.item@1', + from: 'output', + }), + code: 'FORGED_CONTROLLER_ITEM_CLAIM', + }, + ])('rejects $name before runtime', ({ mutate, code }) => { + const value = config(); + mutate(value); + try { + compileClaimPlan(value); + throw new Error('expected expansion plan rejection'); + } catch (error) { + expect(error).toBeInstanceOf(InstancePlanError); + expect((error as InstancePlanError).code).toBe(code); + } + }); + + it('preserves C1 and legacy configurations when expansion syntax is absent', () => { + const legacy = compileClaimPlan({ + version: '1.0', + checks: { first: { type: 'noop' }, second: { type: 'noop', depends_on: 'first' } }, + }); + expect(legacy.active).toBe(false); + expect(legacy.expansionPlan.active).toBe(false); + expect(legacy.effectiveDependenciesByCheck).toEqual({ first: [], second: ['first'] }); + + const c1 = config(); + delete c1.subgraphs; + delete c1.checks.discover.expand; + expect(compileClaimPlan(c1).expansionPlan.active).toBe(false); + }); +});