From 6f332f8628b1b09b8ead10f40423f462190e85ba Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:32:28 +0200 Subject: [PATCH] feat(reconciliation): recovery matrix and simulator composition (C04) --- ...221016Z-c04-recovery-matrix-integration.md | 78 +++ docs/COMPOSITION_MANIFEST.md | 2 +- docs/GATE_P4_CHECKLIST.md | 4 +- packages/reconciliation/README.md | 13 + .../docs/C04_RECOVERY_MATRIX_REPORT.md | 62 ++ .../reconciliation/docs/C04_SIMULATOR_LOCK.md | 34 + .../docs/GATE_P4_RECOVERY_REPLACEMENT.md | 57 ++ .../docs/SUBGRAPH_MCP_CHECKLIST.md | 32 + .../recovery-command-pack-v1.schema.json | 200 ++++++ packages/reconciliation/src/index.ts | 3 + .../reconciliation/src/recovery-matrix.ts | 240 +++++++ .../reconciliation/src/service-simulator.ts | 182 ++++++ packages/reconciliation/src/service.ts | 587 ++++++++++++++++++ .../test/service-integration.test.ts | 302 +++++++++ 14 files changed, 1794 insertions(+), 2 deletions(-) create mode 100644 .agent/context/20260907T221016Z-c04-recovery-matrix-integration.md create mode 100644 packages/reconciliation/docs/C04_RECOVERY_MATRIX_REPORT.md create mode 100644 packages/reconciliation/docs/C04_SIMULATOR_LOCK.md create mode 100644 packages/reconciliation/docs/GATE_P4_RECOVERY_REPLACEMENT.md create mode 100644 packages/reconciliation/docs/SUBGRAPH_MCP_CHECKLIST.md create mode 100644 packages/reconciliation/schemas/recovery-command-pack-v1.schema.json create mode 100644 packages/reconciliation/src/recovery-matrix.ts create mode 100644 packages/reconciliation/src/service-simulator.ts create mode 100644 packages/reconciliation/src/service.ts create mode 100644 packages/reconciliation/test/service-integration.test.ts diff --git a/.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md b/.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md new file mode 100644 index 0000000..e425b9c --- /dev/null +++ b/.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md @@ -0,0 +1,78 @@ +# Session Context: C04 recovery matrix and integration + +## Date/time + +- UTC: 2026-09-07T22:10:16Z + +## User goal + +Implement Coder C milestone C04 and review the finished candidate with fresh +FreePi processes using GLM 5.3. + +## Original prompt/request + +"C04 план начинай делать. Делай ревью через free pi glm5-3" + +## Assumptions + +- C02/C03 merged through PR #22 and are the complete simulator baseline for C04. +- C04 remains package-local and zero-submit; live adapter replacement stays at Gate P4. + +## Plan + +1. Add the versioned recovery service and append-only evidence-command seam. +2. Compose local, known-identity, Subgraph MCP, and advisor simulators behind public ports. +3. Execute the complete C04 recovery matrix and publish replacement guidance. +4. Run package/root checks, FreePi Gate A, draft PR CI, and FreePi Gate B. + +## Key decisions + +- Preserve deterministic safety-core authority; model and index outputs stay advisory. +- Make duplicate delivery converge through deterministic record and command identities. + +## Files/components touched + +- `docs/COMPOSITION_MANIFEST.md`: updated reconciliation port reference to `RecoveryService` with Subgraph MCP adapter. +- `docs/GATE_P4_CHECKLIST.md`: references `GATE_P4_RECOVERY_REPLACEMENT.md` and `RecoveryCommandStorePort`. +- `packages/reconciliation`: recovery service, simulators, full pre-live matrix, schemas, and integration tests. +- `.agent/context/20260907T221016Z-c04-recovery-matrix-integration.md`: this session record. + +## Commands/checks + +- `git fetch origin develop` - PASS. +- Current `develop` rebased with PR #27 at `4295bd9c909d39bd750eed8d756ec4b0abe77958`. +- `pnpm format:check` - PASS (Prettier 3.9.6). +- `pnpm lint` - PASS (ESLint 10.10.0, 0 issues). +- `pnpm typecheck` - PASS (TypeScript 6.0.3, 0 issues). +- `pnpm check:generated` - PASS (contracts current). +- `pnpm validate:fixtures` - PASS (9 fixtures validated). +- `pnpm test` - PASS (35 test files, 504 tests passing; 66 in reconciliation package). +- `npx markdownlint-cli2` - PASS (88 markdown files, 0 issues). +- `git diff --check` - PASS (no whitespace or merge marker issues). + +## External-doc findings + +- None; C04 is a frozen simulator and integration milestone. + +## Unresolved questions + +- None. + +## Git and PR state + +- Branch: `milestone/c04-recovery-matrix-integration` +- Base: `origin/develop` at `4295bd9c909d39bd750eed8d756ec4b0abe77958` +- Commit: uncommitted +- PR: not created +- CI: not applicable + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Stage candidate files and capture candidate tree SHA (`git write-tree`). +2. Run FreePi Gate A pre-push review with `npx free-pi-cli` (GLM 5.3). +3. Commit, push branch, open draft PR, verify CI, then run FreePi Gate B. diff --git a/docs/COMPOSITION_MANIFEST.md b/docs/COMPOSITION_MANIFEST.md index 69e810b..c87079a 100644 --- a/docs/COMPOSITION_MANIFEST.md +++ b/docs/COMPOSITION_MANIFEST.md @@ -36,7 +36,7 @@ All ports conform to frozen definitions in `@oneshot/contracts`: - **Settlement**: `ArcSettlementAdapter` (`@oneshot/privy-adapter`, owned by Coder B) - **Authorization**: `PrivyAuthorizationAdapter` (`@oneshot/privy-adapter`, owned by Coder B) -- **Reconciliation**: Subgraph MCP Recovery Engine (`@oneshot/reconciliation-subgraph`, owned by Coder C) +- **Reconciliation**: `RecoveryService` with a Subgraph MCP adapter (`@oneshot/reconciliation`, owned by Coder C) ## Environment Configuration diff --git a/docs/GATE_P4_CHECKLIST.md b/docs/GATE_P4_CHECKLIST.md index c8a8774..868fead 100644 --- a/docs/GATE_P4_CHECKLIST.md +++ b/docs/GATE_P4_CHECKLIST.md @@ -42,7 +42,9 @@ readiness probing they build on. See - Verify contract version `1.0.0`. 3. **Replace Recovery Engine**: - - Wire `SubgraphMcpRecoveryEngine` into `reconcile_intent` task in `createTaskList`. + - Follow `packages/reconciliation/docs/GATE_P4_RECOVERY_REPLACEMENT.md`. + - Inject `RecoveryService` into the `reconcile_intent` task in `createTaskList`. + - Keep A-owned persistence behind `RecoveryCommandStorePort`; the recovery package does not write A tables. 4. **Freeze the frontend boundary**: - Revalidate the A01 OpenAPI v1 artifact against the composed backend. diff --git a/packages/reconciliation/README.md b/packages/reconciliation/README.md index fe2ffed..564a568 100644 --- a/packages/reconciliation/README.md +++ b/packages/reconciliation/README.md @@ -12,6 +12,11 @@ variable. This package cannot submit or retry a settlement. It imports no domain, storage, Privy, or Arc implementation and exposes no `SettlementPort`. +C04 adds a recovery service around the frozen source ports and deterministic +safety core. It emits versioned, append-only observation and decision commands +through `RecoveryCommandStorePort`; an A-owned consumer remains responsible for +durable writes and state-version checks. + ## Boundary The parser accepts the public MCP `CallToolResult` shape used by @@ -50,6 +55,9 @@ The package participates in the root pnpm workspace and TypeScript project. - `src/simulator.ts`: credential-free deterministic Subgraph MCP scenarios. - `src/agent-simulator.ts`: C02 credential-free deterministic RecoveryAdvisorPort simulator. - `src/safety-core.ts`: C02 deterministic recovery safety core. +- `src/service.ts`: C04 recovery job handler and evidence-command persistence seam. +- `src/service-simulator.ts`: C04 A/B/MCP/model simulator composition and atomic dedupe store. +- `src/recovery-matrix.ts`: C04 complete sanitized pre-live matrix runner and Markdown renderer. - `docs/recovery-action-matrix.md`: C02 four-action advisory and safety core disposition matrix. - `schemas/chaos-timeline-v1.schema.json`: C03 chaos timeline scenario schema. - `src/chaos/`: C03 cross-source failure injection harness, timeline DSL, and scenario runner. @@ -57,3 +65,8 @@ The package participates in the root pnpm workspace and TypeScript project. - `docs/ESCALATION_RUNBOOK.md`: C03 operator escalation runbook (strict no-blind-retry policy). - `docs/removal-value-matrix.md`: Graph removal/value comparison. - `docs/live-value-gate.md`: sanitized live MCP/agent spike protocol and current decision. +- `schemas/recovery-command-pack-v1.schema.json`: append-only C04 command pack contract. +- `docs/C04_RECOVERY_MATRIX_REPORT.md`: generated service and chaos matrix report. +- `docs/C04_SIMULATOR_LOCK.md`: exact simulator identities and unlock conditions. +- `docs/GATE_P4_RECOVERY_REPLACEMENT.md`: public-port replacement map and known live gaps. +- `docs/SUBGRAPH_MCP_CHECKLIST.md`: deployment, freshness, data-boundary, and disable checks. diff --git a/packages/reconciliation/docs/C04_RECOVERY_MATRIX_REPORT.md b/packages/reconciliation/docs/C04_RECOVERY_MATRIX_REPORT.md new file mode 100644 index 0000000..4ffc13b --- /dev/null +++ b/packages/reconciliation/docs/C04_RECOVERY_MATRIX_REPORT.md @@ -0,0 +1,62 @@ +# C04 Recovery Matrix Report + +`runRecoveryMatrix()` executes the C04 service scenarios and the complete C03 +chaos catalog through the public `@oneshot/reconciliation` surface. Every row +records the stable intent, starting/final state, evidence sources, authority, +freshness, deterministic decision, and external-submission count. + +The committed table below is generated from the verified build output. It +contains no provider bodies, credentials, or runtime configuration. + + + +| Scenario | Stable intent | Start | Final | Evidence | Authority | Freshness | Decision | External submissions | Pass | +| --------------------------------------- | --------------------------------------------------------- | --------- | ----------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------- | ---------------- | -------------------: | ---- | +| normal-authoritative-success | intent-matrix-normal | UNKNOWN | COMMITTED | ONESHOT + ARC + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + AUTHORITATIVE_CHAIN_EVIDENCE + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | MARK_COMMITTED | 0 | PASS | +| advisor-wait | intent-matrix-wait | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| advisor-reconcile | intent-matrix-reconcile | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | READ_ONLY_LOOKUP | 0 | PASS | +| advisor-escalate | intent-matrix-escalate | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | ESCALATE_UNKNOWN | 0 | PASS | +| advisor-return-existing-result | intent-matrix-return-existing-result | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| invalid-model-output | intent-matrix-invalid-model | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| duplicate-delivery | intent-matrix-duplicate | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| ten-concurrent-recovery-workers | intent-matrix-concurrency | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| privy-policy-denial | intent-matrix-privy-denial | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| downstream-failure-after-payment | intent-matrix-downstream-failure | COMMITTED | COMMITTED | ONESHOT + ARC + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + AUTHORITATIVE_CHAIN_EVIDENCE + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| two-agent-instances | intent-matrix-two-agents | UNKNOWN | UNKNOWN | ONESHOT + PRIVY + THE_GRAPH | AUTHORITATIVE_ONESHOT + PROVIDER_OBSERVATION + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY | FRESH | HOLD_UNKNOWN | 0 | PASS | +| crash-before-submission | intent-chaos-crash-before-submission-1001 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| lost-response-after-submission | intent-chaos-lost-response-after-submission-1002 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| restart-between-transitions | intent-chaos-restart-between-transitions-1003 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | READ_ONLY_LOOKUP | 0 | PASS | +| mcp-empty-fresh | intent-chaos-mcp-empty-fresh-2001 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | FRESH | HOLD_UNKNOWN | 0 | PASS | +| mcp-lagging-head | intent-chaos-mcp-lagging-head-2002 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | LAGGING | READ_ONLY_LOOKUP | 0 | PASS | +| mcp-provider-health-error | intent-chaos-mcp-provider-health-error-2003 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | UNHEALTHY | READ_ONLY_LOOKUP | 0 | PASS | +| mcp-wrong-tool-deployment | intent-chaos-mcp-wrong-tool-deployment-2004 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| mcp-oversized-result | intent-chaos-mcp-oversized-result-2005 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| mcp-hostile-injection | intent-chaos-mcp-hostile-injection-2006 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| mcp-delayed-result | intent-chaos-mcp-delayed-result-2100 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | LAGGING | HOLD_UNKNOWN | 0 | PASS | +| mcp-missing-freshness | intent-chaos-mcp-missing-freshness-2101 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | UNKNOWN_FRESHNESS | HOLD_UNKNOWN | 0 | PASS | +| mcp-query-failure | intent-chaos-mcp-query-failure-2102 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| mcp-duplicate-events | intent-chaos-mcp-duplicate-events-2103 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | FRESH | HOLD_UNKNOWN | 0 | PASS | +| mcp-out-of-order-events | intent-chaos-mcp-out-of-order-events-2104 | UNKNOWN | UNKNOWN | ONESHOT + THE_GRAPH + LLM | AUTHORITATIVE_ONESHOT + NON_AUTHORITATIVE_CANDIDATE_DISCOVERY + ADVISORY_AGENT_OBSERVATION | FRESH | ESCALATE_UNKNOWN | 0 | PASS | +| mcp-malformed-result | intent-chaos-mcp-malformed-result-2105 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| contradiction-privy-success-arc-revert | intent-chaos-contradiction-privy-success-arc-revert-3001 | UNKNOWN | FAILED_SAFE | ONESHOT + ARC + PRIVY + LLM | AUTHORITATIVE_ONESHOT + AUTHORITATIVE_CHAIN_EVIDENCE + PROVIDER_OBSERVATION + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | MARK_FAILED_SAFE | 0 | PASS | +| contradiction-recipient-mismatch | intent-chaos-contradiction-recipient-mismatch-3002 | UNKNOWN | UNKNOWN | ONESHOT + ARC + LLM | AUTHORITATIVE_ONESHOT + AUTHORITATIVE_CHAIN_EVIDENCE + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | ESCALATE_UNKNOWN | 0 | PASS | +| contradiction-amount-mismatch | intent-chaos-contradiction-amount-mismatch-3003 | UNKNOWN | UNKNOWN | ONESHOT + ARC + LLM | AUTHORITATIVE_ONESHOT + AUTHORITATIVE_CHAIN_EVIDENCE + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | ESCALATE_UNKNOWN | 0 | PASS | +| agent-unsupported-action | intent-chaos-agent-unsupported-action-4001 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| agent-fabricated-evidence-id | intent-chaos-agent-fabricated-evidence-id-4002 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| agent-unverified-return-existing-result | intent-chaos-agent-unverified-return-existing-result-4003 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| agent-malformed-output | intent-chaos-agent-malformed-output-4100 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| agent-timeout | intent-chaos-agent-timeout-4101 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| agent-nondeterministic-prose | intent-chaos-agent-nondeterministic-prose-4102 | UNKNOWN | UNKNOWN | ONESHOT + LLM | AUTHORITATIVE_ONESHOT + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | HOLD_UNKNOWN | 0 | PASS | +| authoritative-confirmed-success | intent-chaos-authoritative-confirmed-success-5001 | UNKNOWN | COMMITTED | ONESHOT + ARC + LLM | AUTHORITATIVE_ONESHOT + AUTHORITATIVE_CHAIN_EVIDENCE + ADVISORY_AGENT_OBSERVATION | UNAVAILABLE | MARK_COMMITTED | 0 | PASS | + + + +## Required conclusions + +- Every recovery run has `externalSubmissionCount = 0`. +- Duplicate delivery, ten concurrent workers, and two agent instances converge + on one immutable command pack. +- `RETURN_EXISTING_RESULT` cannot commit without independently authoritative Arc proof. +- Missing freshness, wrong MCP identity, malformed data, injection text, and + unknown model actions fail closed. +- A downstream failure cannot erase an already committed settlement. diff --git a/packages/reconciliation/docs/C04_SIMULATOR_LOCK.md b/packages/reconciliation/docs/C04_SIMULATOR_LOCK.md new file mode 100644 index 0000000..e2b90b2 --- /dev/null +++ b/packages/reconciliation/docs/C04_SIMULATOR_LOCK.md @@ -0,0 +1,34 @@ +# C04 Simulator Lock + +C04 closes against deterministic, credential-free simulators. It does not claim +that Arc, Privy, Subgraph MCP, or an external model was called live. + +## Frozen simulator identities + +| Boundary | Simulator | Contract identity | +| ------------------------- | ------------------------------------ | ------------------------------------ | +| A local state | `SimulatorLocalRecoveryStatePort` | `local-recovery-snapshot-v1` | +| B known-identity evidence | `SimulatorKnownIdentityEvidencePort` | `recovery-evidence-v1` | +| The Graph | `SimulatorSubgraphMcpRecoveryPort` | `c01-simulator-v1` / `index-view-v1` | +| Recovery agent | `RecoveryAgentSimulator` | `recovery-advisor-v1` | +| Command seam | `InMemoryRecoveryCommandStore` | `recovery-command-pack-v1` | + +All simulator timestamps, request identities, block windows, and model outputs +are fixed fixtures. Replaying one event produces the same pack ID and append +command IDs. The command store admits one immutable pack per event ID. + +## Safety boundary + +- The service imports no A or B implementation path. +- It exposes no settlement port and records `externalSubmissionCount: 0`. +- The Graph and the model remain observations. The deterministic core owns the + disposition. +- Raw provider bodies, authorization headers, credentials, and secrets are + rejected before the append-only command seam. +- A contract mismatch returns `HELD` without emitting a command pack. + +## Unlock condition + +Replace a simulator only through +[`GATE_P4_RECOVERY_REPLACEMENT.md`](GATE_P4_RECOVERY_REPLACEMENT.md). C05 remains +blocked until the integrated Gate P4 proofs pass. diff --git a/packages/reconciliation/docs/GATE_P4_RECOVERY_REPLACEMENT.md b/packages/reconciliation/docs/GATE_P4_RECOVERY_REPLACEMENT.md new file mode 100644 index 0000000..364521a --- /dev/null +++ b/packages/reconciliation/docs/GATE_P4_RECOVERY_REPLACEMENT.md @@ -0,0 +1,57 @@ +# Gate P4 Recovery Replacement Guide + +Gate P4 replaces C04 simulators at their public ports. The recovery service and +deterministic safety core remain unchanged. + +| C04 port | Simulator | Gate P4 replacement | Required proof | +| --------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `LocalRecoveryStatePort` | `SimulatorLocalRecoveryStatePort` | Thin adapter over A's public `IntentLedger` reads | Stable intent, state/version, attempt identity, persisted correlation window | +| `KnownIdentityEvidencePort` | `SimulatorKnownIdentityEvidencePort` | Reviewed bridge over `@oneshot/privy-adapter` `EvidencePort` and persisted Arc identity | Exact request binding and final Arc proof or revert | +| `SubgraphMcpRecoveryPort` | `SimulatorSubgraphMcpRecoveryPort` | Deployment-pinned Subgraph MCP client normalized by `normalizeSubgraphMcpTrace` | Expected server, tool, deployment, manifest, query digest, `_meta`, and lag | +| `RecoveryAdvisorPort` | `RecoveryAgentSimulator` | Structured-output recovery model adapter | Four-action enum, model/prompt version, bounded references, rejection tests | +| `RecoveryCommandStorePort` | `InMemoryRecoveryCommandStore` | A-owned append-only command consumer | Atomic event-ID dedupe and expected state-version check | + +## Composition point + +`apps/worker/src/worker.ts` currently leaves `reconcile_intent` empty. Gate P4 +injects one `RecoveryService` and calls `handle` with the durable outbox event +identity. The handler returns append commands and a reconciliation command. The +A-owned consumer applies them atomically; C code never imports storage internals +or writes A tables. + +## Compatibility checks + +Before readiness may pass, verify: + +1. `@oneshot/reconciliation` is `0.1.0` and all C04 schema constants match. +2. `@oneshot/privy-adapter` exposes `EvidencePort` with contract pack + `frozen-v1` and adapter contract `settlement-adapter-contract-v1`. +3. The configured network is Arc Testnet `eip155:5042002`. +4. The Subgraph MCP checks in + [`SUBGRAPH_MCP_CHECKLIST.md`](SUBGRAPH_MCP_CHECKLIST.md) pass. +5. The model returns only `WAIT`, `RECONCILE`, `ESCALATE`, or + `RETURN_EXISTING_RESULT`. +6. The command consumer rejects a stale expected state version and deduplicates + the same event under concurrent delivery. + +## Known Gate P4 gap + +The current B `EvidencePort.lookup` returns a terminal classification but not a +sanitized proof envelope containing the verified transaction, block, and log +identity required by `KnownIdentityEvidencePort`. Gate P4 must add a reviewed +public bridge or enrich that public result. C04 does not fabricate the missing +metadata. + +## Credential boundary + +Privy credentials, Graph API keys, model credentials, and wallet material stay +in the runtime secret store. Only sanitized observations and stable public +identities cross into `RecoveryCommandPack`. Provider request/response bodies +never cross the port. + +## Safe disable and no-index baseline + +When Subgraph MCP is disabled or unhealthy, known transaction hashes can still +be checked through direct Privy/Arc evidence. A hashless `UNKNOWN` intent stays +`UNKNOWN` and escalates; absence from an index never permits another payment. +Disable the recovery model independently by substituting deterministic `WAIT`. diff --git a/packages/reconciliation/docs/SUBGRAPH_MCP_CHECKLIST.md b/packages/reconciliation/docs/SUBGRAPH_MCP_CHECKLIST.md new file mode 100644 index 0000000..1a293db --- /dev/null +++ b/packages/reconciliation/docs/SUBGRAPH_MCP_CHECKLIST.md @@ -0,0 +1,32 @@ +# Subgraph MCP Gate P4 Checklist + +## Target identity + +- [ ] Pin the reviewed Subgraph deployment ID and manifest CID. +- [ ] Require the configured MCP server name and version. +- [ ] Require tool `execute_query_by_deployment_id`. +- [ ] Require query identity `OneShotRecoveryCandidatesV1` and its frozen digest. +- [ ] Reject any tool, deployment, manifest, query, or variable mismatch. + +## Live data and freshness + +- [ ] Use a live Graph provider; local and static fixtures remain labelled simulator evidence. +- [ ] Require `_meta.deployment`, indexed block number/hash, timestamp, and indexing-error state. +- [ ] Observe the Arc chain head independently. +- [ ] Start with `maxLagBlocks: 5`; a larger production threshold requires a reviewed change. +- [ ] Treat missing freshness, lag, indexing errors, timeout, empty results, and multiple candidates as a hold or escalation. + +## Data boundary + +- [ ] Limit candidates to 25, GraphQL result text to 128 KiB, and the MCP envelope to 160 KiB. +- [ ] Bind network, sender, token, recipient, amount, and block window before exposing a candidate. +- [ ] Persist MCP call identity, retrieval time, freshness, evidence references, and digest. +- [ ] Never persist raw MCP/provider bodies, headers, Graph API keys, or injected instructions. +- [ ] Verify every discovered transaction through authoritative Arc evidence before committing. + +## Degradation and disable + +- [ ] Run wrong-tool, wrong-deployment, malformed, oversized, injected, delayed, empty, duplicate, and out-of-order fixtures. +- [ ] Keep direct known-hash recovery runnable with Subgraph MCP disabled. +- [ ] Keep hashless intents `UNKNOWN` when the index path is disabled or inconclusive. +- [ ] Confirm the recovery path has no settlement submission capability. diff --git a/packages/reconciliation/schemas/recovery-command-pack-v1.schema.json b/packages/reconciliation/schemas/recovery-command-pack-v1.schema.json new file mode 100644 index 0000000..84ca057 --- /dev/null +++ b/packages/reconciliation/schemas/recovery-command-pack-v1.schema.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://oneshot.local/schemas/recovery-command-pack-v1.schema.json", + "title": "OneShot Recovery Command Pack v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "packId", + "eventId", + "businessIntentId", + "sourceStateVersion", + "generatedAt", + "appendCommands", + "reconciliationCommand", + "recoveryView", + "externalSubmissionCount" + ], + "properties": { + "schemaVersion": { "const": "recovery-command-pack-v1" }, + "packId": { "type": "string", "minLength": 1 }, + "eventId": { "type": "string", "minLength": 1 }, + "businessIntentId": { "type": "string", "minLength": 1 }, + "sourceStateVersion": { "type": "string", "minLength": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "appendCommands": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/appendCommand" } + }, + "reconciliationCommand": { "$ref": "reconciliation-command-v1.schema.json" }, + "recoveryView": { "$ref": "recovery-view-v1.schema.json" }, + "externalSubmissionCount": { "const": 0 } + }, + "$defs": { + "appendCommand": { + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "commandId", + "operation", + "businessIntentId", + "expectedStateVersion", + "record" + ], + "properties": { + "schemaVersion": { "const": "append-recovery-record-v1" }, + "commandId": { "type": "string", "minLength": 1 }, + "operation": { "const": "APPEND_RECOVERY_RECORD" }, + "businessIntentId": { "type": "string", "minLength": 1 }, + "expectedStateVersion": { "type": "string", "minLength": 1 }, + "record": { "$ref": "#/$defs/record" } + } + }, + "record": { + "oneOf": [{ "$ref": "#/$defs/observationRecord" }, { "$ref": "#/$defs/decisionRecord" }] + }, + "recordBase": { + "type": "object", + "required": [ + "schemaVersion", + "recordId", + "businessIntentId", + "authorityClass", + "retrievedAt", + "freshness", + "blockNumber", + "reason", + "evidenceReferences", + "digest", + "provenance" + ], + "properties": { + "schemaVersion": { "const": "recovery-record-v1" }, + "recordId": { "type": "string", "minLength": 1 }, + "businessIntentId": { "type": "string", "minLength": 1 }, + "authorityClass": { + "enum": [ + "AUTHORITATIVE_ONESHOT", + "AUTHORITATIVE_CHAIN_EVIDENCE", + "PROVIDER_OBSERVATION", + "NON_AUTHORITATIVE_CANDIDATE_DISCOVERY", + "ADVISORY_AGENT_OBSERVATION" + ] + }, + "retrievedAt": { "type": "string", "format": "date-time" }, + "freshness": { + "type": ["string", "null"], + "enum": ["FRESH", "LAGGING", "UNHEALTHY", "UNAVAILABLE", "UNKNOWN_FRESHNESS", null] + }, + "blockNumber": { "type": ["string", "null"] }, + "reason": { "type": "string", "maxLength": 500 }, + "evidenceReferences": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "digest": { "type": "string", "minLength": 64, "maxLength": 64 }, + "provenance": { "$ref": "#/$defs/provenance" } + } + }, + "observationRecord": { + "allOf": [ + { "$ref": "#/$defs/recordBase" }, + { + "type": "object", + "required": ["recordType", "source"], + "properties": { + "recordType": { "const": "OBSERVATION" }, + "source": { "enum": ["ONESHOT", "PRIVY", "ARC", "THE_GRAPH"] } + } + } + ], + "unevaluatedProperties": false + }, + "decisionRecord": { + "allOf": [ + { "$ref": "#/$defs/recordBase" }, + { + "type": "object", + "required": ["recordType", "source", "advisoryAction", "coreDisposition"], + "properties": { + "recordType": { "const": "DECISION" }, + "source": { "const": "LLM" }, + "advisoryAction": { + "enum": ["WAIT", "RECONCILE", "ESCALATE", "RETURN_EXISTING_RESULT"] + }, + "coreDisposition": { + "enum": [ + "HOLD_UNKNOWN", + "READ_ONLY_LOOKUP", + "ESCALATE_UNKNOWN", + "MARK_COMMITTED", + "MARK_FAILED_SAFE" + ] + } + } + } + ], + "unevaluatedProperties": false + }, + "provenance": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "source", "sourceVersion"], + "properties": { + "kind": { "const": "SOURCE" }, + "source": { "enum": ["ONESHOT", "PRIVY", "ARC"] }, + "sourceVersion": { "const": "recovery-evidence-v1" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "serverName", + "serverVersion", + "deploymentId", + "manifestCid", + "toolName", + "queryName", + "queryDigest" + ], + "properties": { + "kind": { "const": "MCP" }, + "serverName": { "type": "string" }, + "serverVersion": { "type": "string" }, + "deploymentId": { "type": "string" }, + "manifestCid": { "type": "string" }, + "toolName": { "const": "execute_query_by_deployment_id" }, + "queryName": { "const": "OneShotRecoveryCandidatesV1" }, + "queryDigest": { "type": "string", "minLength": 64, "maxLength": 64 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "modelIdentity"], + "properties": { + "kind": { "const": "MODEL" }, + "modelIdentity": { + "type": "object", + "additionalProperties": false, + "required": ["modelName", "modelVersion", "promptVersion"], + "properties": { + "modelName": { "type": "string" }, + "modelVersion": { "type": "string" }, + "promptVersion": { "type": "string" } + } + } + } + } + ] + } + } +} diff --git a/packages/reconciliation/src/index.ts b/packages/reconciliation/src/index.ts index 80f0cc7..c1d8376 100644 --- a/packages/reconciliation/src/index.ts +++ b/packages/reconciliation/src/index.ts @@ -32,5 +32,8 @@ export { type RecoveryAgentSimulatorOptions, type SimulatorScenarioName, } from './agent-simulator.js'; +export * from './service.js'; +export * from './service-simulator.js'; +export * from './recovery-matrix.js'; export * from './chaos/index.js'; export * from './types.js'; diff --git a/packages/reconciliation/src/recovery-matrix.ts b/packages/reconciliation/src/recovery-matrix.ts new file mode 100644 index 0000000..e10fc0a --- /dev/null +++ b/packages/reconciliation/src/recovery-matrix.ts @@ -0,0 +1,240 @@ +import { runChaosMatrix } from './chaos/runner.js'; +import { + InMemoryRecoveryCommandStore, + createRecoverySimulatorComposition, +} from './service-simulator.js'; +import type { RecoveryServiceResult } from './service.js'; + +export interface RecoveryMatrixRow { + readonly scenario: string; + readonly stableIntent: string; + readonly startingState: 'SUBMITTING' | 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE'; + readonly finalState: 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE'; + readonly evidenceSources: readonly string[]; + readonly authorityClasses: readonly string[]; + readonly freshness: string; + readonly decision: string; + readonly externalSubmissionCount: number; + readonly passed: boolean; +} + +function rowFromService( + scenario: string, + startingState: RecoveryMatrixRow['startingState'], + result: RecoveryServiceResult, + extraPass = true, +): RecoveryMatrixRow { + const pack = result.pack; + const observationRecords = + pack?.appendCommands + .map((command) => command.record) + .filter((record) => record.recordType === 'OBSERVATION') ?? []; + return { + scenario, + stableIntent: pack?.businessIntentId ?? 'held-before-command-pack', + startingState, + finalState: pack?.reconciliationCommand.targetState ?? 'UNKNOWN', + evidenceSources: [...new Set(observationRecords.map((record) => record.source))], + authorityClasses: [...new Set(observationRecords.map((record) => record.authorityClass))], + freshness: pack?.recoveryView.indexHealth ?? 'UNAVAILABLE', + decision: pack?.reconciliationCommand.commandType ?? 'FAIL_CLOSED', + externalSubmissionCount: result.externalSubmissionCount, + passed: + extraPass && + result.externalSubmissionCount === 0 && + (pack === null || pack.reconciliationCommand.settlementPermission === 'NEVER'), + }; +} + +async function runServiceRows(): Promise { + const rows: RecoveryMatrixRow[] = []; + const normal = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-normal', + eventId: 'event-matrix-normal', + agentScenario: 'return-existing-result', + }); + rows.push( + rowFromService( + 'normal-authoritative-success', + 'UNKNOWN', + await normal.service.handle(normal.job), + ), + ); + + for (const action of ['wait', 'reconcile', 'escalate', 'return-existing-result'] as const) { + const composition = createRecoverySimulatorComposition({ + businessIntentId: `intent-matrix-${action}`, + eventId: `event-matrix-${action}`, + agentScenario: action, + withArcProof: false, + }); + rows.push( + rowFromService( + `advisor-${action}`, + 'UNKNOWN', + await composition.service.handle(composition.job), + ), + ); + } + + const invalid = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-invalid-model', + eventId: 'event-matrix-invalid-model', + agentScenario: 'unsupported-action', + withArcProof: false, + }); + rows.push( + rowFromService('invalid-model-output', 'UNKNOWN', await invalid.service.handle(invalid.job)), + ); + + const duplicate = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-duplicate', + eventId: 'event-matrix-duplicate', + withArcProof: false, + }); + const duplicateFirst = await duplicate.service.handle(duplicate.job); + const duplicateSecond = await duplicate.service.handle(duplicate.job); + rows.push( + rowFromService( + 'duplicate-delivery', + 'UNKNOWN', + duplicateSecond, + duplicateFirst.status === 'PROCESSED' && + duplicateSecond.status === 'DUPLICATE' && + duplicate.commandStore.size === 1 && + duplicateFirst.pack?.packId === duplicateSecond.pack?.packId, + ), + ); + + const concurrent = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-concurrency', + eventId: 'event-matrix-concurrency', + withArcProof: false, + }); + const concurrentResults = await Promise.all( + Array.from({ length: 10 }, () => concurrent.service.handle(concurrent.job)), + ); + rows.push( + rowFromService( + 'ten-concurrent-recovery-workers', + 'UNKNOWN', + concurrentResults[0]!, + concurrent.commandStore.size === 1 && + concurrentResults.every( + (result) => result.pack?.packId === concurrentResults[0]?.pack?.packId, + ), + ), + ); + + const denied = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-privy-denial', + eventId: 'event-matrix-privy-denial', + privyStatus: 'FAILED', + withArcProof: false, + agentScenario: 'wait', + }); + rows.push( + rowFromService('privy-policy-denial', 'UNKNOWN', await denied.service.handle(denied.job)), + ); + + const downstream = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-downstream-failure', + eventId: 'event-matrix-downstream-failure', + durableState: 'COMMITTED', + agentScenario: 'wait', + }); + rows.push( + rowFromService( + 'downstream-failure-after-payment', + 'COMMITTED', + await downstream.service.handle(downstream.job), + ), + ); + + const sharedStore = new InMemoryRecoveryCommandStore(); + const firstAgent = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-two-agents', + eventId: 'event-matrix-two-agents', + withArcProof: false, + commandStore: sharedStore, + }); + const secondAgent = createRecoverySimulatorComposition({ + businessIntentId: 'intent-matrix-two-agents', + eventId: 'event-matrix-two-agents', + withArcProof: false, + commandStore: sharedStore, + }); + const twoAgentResults = await Promise.all([ + firstAgent.service.handle(firstAgent.job), + secondAgent.service.handle(secondAgent.job), + ]); + rows.push( + rowFromService( + 'two-agent-instances', + 'UNKNOWN', + twoAgentResults[0]!, + sharedStore.size === 1 && + twoAgentResults[0]?.pack?.packId === twoAgentResults[1]?.pack?.packId, + ), + ); + + return rows; +} + +export async function runRecoveryMatrix(): Promise { + const chaosRows: RecoveryMatrixRow[] = runChaosMatrix().map((report) => { + const evidenceSources = [ + ...report.view.authoritativeEvidence.map((record) => record.source), + ...report.view.providerObservations.map((record) => record.source), + ...(report.view.indexHealth === 'UNAVAILABLE' ? [] : ['THE_GRAPH']), + 'LLM', + ]; + const authorityClasses = [ + ...report.view.authoritativeEvidence.map((record) => record.authorityClass), + ...report.view.providerObservations.map((record) => record.authorityClass), + ...(report.view.indexHealth === 'UNAVAILABLE' + ? [] + : ['NON_AUTHORITATIVE_CANDIDATE_DISCOVERY']), + 'ADVISORY_AGENT_OBSERVATION', + ]; + return { + scenario: report.scenarioId, + stableIntent: report.view.businessIntentId, + startingState: 'UNKNOWN', + finalState: report.command.targetState, + evidenceSources: [...new Set(evidenceSources)], + authorityClasses: [...new Set(authorityClasses)], + freshness: report.view.indexHealth, + decision: report.command.commandType, + externalSubmissionCount: report.externalSubmissionCount, + passed: report.passed, + }; + }); + + return [...(await runServiceRows()), ...chaosRows]; +} + +export function renderRecoveryMatrixMarkdown(rows: readonly RecoveryMatrixRow[]): string { + const header = + '| Scenario | Stable intent | Start | Final | Evidence | Authority | Freshness | Decision | External submissions | Pass |'; + const divider = '| --- | --- | --- | --- | --- | --- | --- | --- | ---: | --- |'; + const body = rows.map((row) => + [ + row.scenario, + row.stableIntent, + row.startingState, + row.finalState, + row.evidenceSources.join(' + ') || 'NONE', + row.authorityClasses.join(' + ') || 'NONE', + row.freshness, + row.decision, + String(row.externalSubmissionCount), + row.passed ? 'PASS' : 'FAIL', + ] + .map((cell) => cell.replaceAll('|', '\\|')) + .join(' | ') + .replace(/^/u, '| ') + .replace(/$/u, ' |'), + ); + return [header, divider, ...body].join('\n'); +} diff --git a/packages/reconciliation/src/service-simulator.ts b/packages/reconciliation/src/service-simulator.ts new file mode 100644 index 0000000..ca23bb0 --- /dev/null +++ b/packages/reconciliation/src/service-simulator.ts @@ -0,0 +1,182 @@ +import { RecoveryAgentSimulator, type SimulatorScenarioName } from './agent-simulator.js'; +import { createKnownIdentityFixture, createScenario, type ScenarioName } from './simulator.js'; +import { + LOCAL_RECOVERY_SNAPSHOT_VERSION, + RECOVERY_JOB_VERSION, + RecoveryService, + type KnownIdentityEvidencePort, + type LocalRecoverySnapshot, + type LocalRecoveryStatePort, + type RecoveryCommandPack, + type RecoveryCommandStorePort, + type RecoveryCommandStoreResult, + type RecoveryJob, + type SubgraphMcpRecoveryPort, +} from './service.js'; +import type { + EvidenceBinding, + IndexLookupOutcome, + IndexLookupRequest, + KnownIdentityRecoveryEvidence, + SubgraphMcpPolicy, +} from './types.js'; +import { normalizeSubgraphMcpTrace } from './validation.js'; + +export class SimulatorLocalRecoveryStatePort implements LocalRecoveryStatePort { + readCount = 0; + + constructor(readonly snapshot: LocalRecoverySnapshot) {} + + async read(businessIntentId: string): Promise { + this.readCount += 1; + if (businessIntentId !== this.snapshot.binding.businessIntentId) { + throw new Error('Intent is not present in the local simulator'); + } + return this.snapshot; + } +} + +export class SimulatorKnownIdentityEvidencePort implements KnownIdentityEvidencePort { + readCount = 0; + + constructor(readonly evidence: KnownIdentityRecoveryEvidence) {} + + async read(binding: EvidenceBinding): Promise { + this.readCount += 1; + if (binding.businessIntentId !== this.evidence.binding.businessIntentId) { + throw new Error('Evidence binding is not present in the provider simulator'); + } + return this.evidence; + } +} + +export class SimulatorSubgraphMcpRecoveryPort implements SubgraphMcpRecoveryPort { + lookupCount = 0; + + constructor(readonly scenario: ReturnType) {} + + async lookup( + request: IndexLookupRequest, + policy: SubgraphMcpPolicy, + ): Promise { + this.lookupCount += 1; + return normalizeSubgraphMcpTrace(request, policy, this.scenario.trace); + } +} + +export class InMemoryRecoveryCommandStore implements RecoveryCommandStorePort { + private readonly packsByEvent = new Map(); + + async findByEventId(eventId: string): Promise { + return this.packsByEvent.get(eventId) ?? null; + } + + async append(pack: RecoveryCommandPack): Promise { + const existing = this.packsByEvent.get(pack.eventId); + if (existing !== undefined) return { status: 'DUPLICATE', pack: existing }; + this.packsByEvent.set(pack.eventId, pack); + return { status: 'APPENDED', pack }; + } + + get size(): number { + return this.packsByEvent.size; + } + + values(): readonly RecoveryCommandPack[] { + return [...this.packsByEvent.values()]; + } +} + +export interface RecoverySimulatorOptions { + readonly businessIntentId?: string | undefined; + readonly eventId?: string | undefined; + readonly requestedAt?: string | undefined; + readonly agentScenario?: SimulatorScenarioName | undefined; + readonly mcpScenario?: ScenarioName | undefined; + readonly withArcProof?: boolean | undefined; + readonly durableState?: LocalRecoverySnapshot['durable']['state'] | undefined; + readonly privyStatus?: + 'PENDING' | 'SUCCEEDED' | 'FAILED' | 'NOT_FOUND' | 'UNAVAILABLE' | undefined; + readonly commandStore?: InMemoryRecoveryCommandStore | undefined; +} + +export interface RecoverySimulatorComposition { + readonly service: RecoveryService; + readonly job: RecoveryJob; + readonly localState: SimulatorLocalRecoveryStatePort; + readonly knownIdentityEvidence: SimulatorKnownIdentityEvidencePort; + readonly subgraphMcp: SimulatorSubgraphMcpRecoveryPort; + readonly advisor: RecoveryAgentSimulator; + readonly commandStore: InMemoryRecoveryCommandStore; +} + +export function createRecoverySimulatorComposition( + options: RecoverySimulatorOptions = {}, +): RecoverySimulatorComposition { + const scenario = createScenario(options.mcpScenario ?? 'fresh'); + const fixture = createKnownIdentityFixture(); + const businessIntentId = options.businessIntentId ?? fixture.binding.businessIntentId; + const binding: EvidenceBinding = { ...fixture.binding, businessIntentId }; + const durableState = options.durableState ?? 'UNKNOWN'; + const evidence: KnownIdentityRecoveryEvidence = { + ...fixture, + binding, + local: { + ...fixture.local, + settlementState: durableState, + }, + privy: + fixture.privy === null + ? null + : { + ...fixture.privy, + requestStatus: options.privyStatus ?? fixture.privy.requestStatus, + }, + arc: options.withArcProof === false ? null : fixture.arc, + }; + const indexRequest: IndexLookupRequest = { + ...scenario.request, + binding, + }; + const snapshot: LocalRecoverySnapshot = { + schemaVersion: LOCAL_RECOVERY_SNAPSHOT_VERSION, + binding, + durable: { + state: durableState, + stateVersion: fixture.local.stateVersion, + attemptCount: 1, + persistedAt: fixture.local.persistedAt, + }, + indexRequest, + mcpPolicy: scenario.policy, + capturedAt: options.requestedAt ?? '2026-09-07T13:10:00.000Z', + }; + const localState = new SimulatorLocalRecoveryStatePort(snapshot); + const knownIdentityEvidence = new SimulatorKnownIdentityEvidencePort(evidence); + const subgraphMcp = new SimulatorSubgraphMcpRecoveryPort(scenario); + const advisor = new RecoveryAgentSimulator({ scenario: options.agentScenario ?? 'auto' }); + const commandStore = options.commandStore ?? new InMemoryRecoveryCommandStore(); + const service = new RecoveryService({ + localState, + knownIdentityEvidence, + subgraphMcp, + advisor, + commandStore, + }); + const job: RecoveryJob = { + schemaVersion: RECOVERY_JOB_VERSION, + eventId: options.eventId ?? `reconcile:${businessIntentId}:${snapshot.durable.stateVersion}`, + businessIntentId, + requestedAt: options.requestedAt ?? '2026-09-07T13:10:00.000Z', + }; + + return { + service, + job, + localState, + knownIdentityEvidence, + subgraphMcp, + advisor, + commandStore, + }; +} diff --git a/packages/reconciliation/src/service.ts b/packages/reconciliation/src/service.ts new file mode 100644 index 0000000..11937bb --- /dev/null +++ b/packages/reconciliation/src/service.ts @@ -0,0 +1,587 @@ +import { buildRecoveryAgentInput, validateAndNormalizeRecommendation } from './agent-contract.js'; +import { buildBoundEvidenceRecords } from './evidence-model.js'; +import { sha256 } from './query.js'; +import { evaluateReconciliation } from './safety-core.js'; +import { + INDEX_VIEW_VERSION, + RECOVERY_EVIDENCE_VERSION, + type BoundEvidenceRecord, + type DetailedRecoveryView, + type EvidenceAuthorityClass, + type EvidenceBinding, + type EvidenceSource, + type IndexLookupOutcome, + type IndexLookupRequest, + type IndexView, + type KnownIdentityRecoveryEvidence, + type ModelIdentity, + type ReconciliationCommand, + type RecoveryAdvisorPort, + type RecoveryRecommendationOutcome, + type SubgraphMcpPolicy, +} from './types.js'; + +export const RECOVERY_JOB_VERSION = 'recovery-job-v1' as const; +export const LOCAL_RECOVERY_SNAPSHOT_VERSION = 'local-recovery-snapshot-v1' as const; +export const RECOVERY_RECORD_VERSION = 'recovery-record-v1' as const; +export const APPEND_RECOVERY_RECORD_VERSION = 'append-recovery-record-v1' as const; +export const RECOVERY_COMMAND_PACK_VERSION = 'recovery-command-pack-v1' as const; + +export type RecoveryServiceIssueCode = + | 'ADVISOR_BOUNDARY_REJECTED' + | 'ADVISOR_UNAVAILABLE' + | 'COMMAND_STORE_UNAVAILABLE' + | 'CONTRACT_VERSION_MISMATCH' + | 'DUPLICATE_EVENT' + | 'EVIDENCE_BINDING_MISMATCH' + | 'EVIDENCE_UNAVAILABLE' + | 'EVENT_ID_CONFLICT' + | 'INVALID_JOB' + | 'LOCAL_STATE_UNAVAILABLE' + | 'MCP_BOUNDARY_REJECTED' + | 'MCP_UNAVAILABLE' + | 'MISSING_FRESHNESS_METADATA' + | 'RAW_PROVIDER_PAYLOAD_REJECTED'; + +export interface RecoveryJob { + readonly schemaVersion: typeof RECOVERY_JOB_VERSION; + readonly eventId: string; + readonly businessIntentId: string; + readonly requestedAt: string; +} + +export interface LocalRecoverySnapshot { + readonly schemaVersion: typeof LOCAL_RECOVERY_SNAPSHOT_VERSION; + readonly binding: EvidenceBinding; + readonly durable: { + readonly state: 'SUBMITTING' | 'UNKNOWN' | 'COMMITTED' | 'FAILED_SAFE'; + readonly stateVersion: string; + readonly attemptCount: number; + readonly persistedAt: string; + }; + readonly indexRequest: IndexLookupRequest; + readonly mcpPolicy: SubgraphMcpPolicy; + readonly capturedAt: string; +} + +export interface LocalRecoveryStatePort { + read(businessIntentId: string): Promise; +} + +export interface KnownIdentityEvidencePort { + read(binding: EvidenceBinding): Promise; +} + +export interface SubgraphMcpRecoveryPort { + lookup(request: IndexLookupRequest, policy: SubgraphMcpPolicy): Promise; +} + +export type RecoveryRecordProvenance = + | { + readonly kind: 'SOURCE'; + readonly source: Exclude; + readonly sourceVersion: string; + } + | { + readonly kind: 'MCP'; + readonly serverName: string; + readonly serverVersion: string; + readonly deploymentId: string; + readonly manifestCid: string; + readonly toolName: string; + readonly queryName: string; + readonly queryDigest: string; + } + | { + readonly kind: 'MODEL'; + readonly modelIdentity: ModelIdentity; + }; + +interface RecoveryRecordBase { + readonly schemaVersion: typeof RECOVERY_RECORD_VERSION; + readonly recordId: string; + readonly businessIntentId: string; + readonly authorityClass: EvidenceAuthorityClass; + readonly retrievedAt: string; + readonly freshness: IndexView['health'] | null; + readonly blockNumber: string | null; + readonly reason: string; + readonly evidenceReferences: readonly string[]; + readonly digest: string; + readonly provenance: RecoveryRecordProvenance; +} + +export interface RecoveryObservationRecord extends RecoveryRecordBase { + readonly recordType: 'OBSERVATION'; + readonly source: Exclude; +} + +export interface RecoveryDecisionRecord extends RecoveryRecordBase { + readonly recordType: 'DECISION'; + readonly source: 'LLM'; + readonly advisoryAction: string; + readonly coreDisposition: ReconciliationCommand['commandType']; +} + +export type RecoveryRecord = RecoveryObservationRecord | RecoveryDecisionRecord; + +export interface AppendRecoveryRecordCommand { + readonly schemaVersion: typeof APPEND_RECOVERY_RECORD_VERSION; + readonly commandId: string; + readonly operation: 'APPEND_RECOVERY_RECORD'; + readonly businessIntentId: string; + readonly expectedStateVersion: string; + readonly record: RecoveryRecord; +} + +export interface RecoveryCommandPack { + readonly schemaVersion: typeof RECOVERY_COMMAND_PACK_VERSION; + readonly packId: string; + readonly eventId: string; + readonly businessIntentId: string; + readonly sourceStateVersion: string; + readonly generatedAt: string; + readonly appendCommands: readonly AppendRecoveryRecordCommand[]; + readonly reconciliationCommand: ReconciliationCommand; + readonly recoveryView: DetailedRecoveryView; + readonly externalSubmissionCount: 0; +} + +export interface RecoveryCommandStoreResult { + readonly status: 'APPENDED' | 'DUPLICATE'; + readonly pack: RecoveryCommandPack; +} + +export interface RecoveryCommandStorePort { + findByEventId(eventId: string): Promise; + append(pack: RecoveryCommandPack): Promise; +} + +export interface RecoveryServiceResult { + readonly status: 'PROCESSED' | 'DUPLICATE' | 'HELD'; + readonly issues: readonly RecoveryServiceIssueCode[]; + readonly pack: RecoveryCommandPack | null; + readonly externalSubmissionCount: 0; +} + +export interface RecoveryServicePorts { + readonly localState: LocalRecoveryStatePort; + readonly knownIdentityEvidence: KnownIdentityEvidencePort; + readonly subgraphMcp: SubgraphMcpRecoveryPort; + readonly advisor: RecoveryAdvisorPort; + readonly commandStore: RecoveryCommandStorePort; +} + +const FORBIDDEN_PAYLOAD_KEYS = new Set([ + 'accesstoken', + 'apikey', + 'authorization', + 'credential', + 'credentials', + 'headers', + 'privatekey', + 'providerbody', + 'rawproviderbody', + 'rawbody', + 'requestbody', + 'responsebody', + 'secret', + 'seedphrase', +]); + +const SENSITIVE_TEXT = + /(?:bearer\s+[a-z0-9._~-]+|(?:api[_-]?key|access[_-]?token|secret|private[_-]?key)\s*[:=]\s*[^\s,;]+)/giu; + +function normalizedKey(key: string): string { + return key.replace(/[^a-z0-9]/giu, '').toLowerCase(); +} + +function hasForbiddenPayload(value: unknown, seen = new Set()): boolean { + if (value === null || typeof value !== 'object') return false; + if (seen.has(value)) return false; + seen.add(value); + + if (Array.isArray(value)) return value.some((item) => hasForbiddenPayload(item, seen)); + + return Object.entries(value as Record).some( + ([key, child]) => + FORBIDDEN_PAYLOAD_KEYS.has(normalizedKey(key)) || hasForbiddenPayload(child, seen), + ); +} + +function sanitizeText(value: string, maxLength = 500): string { + return value.replace(SENSITIVE_TEXT, '[REDACTED]').slice(0, maxLength); +} + +function sameBinding(left: EvidenceBinding, right: EvidenceBinding): boolean { + return ( + left.businessIntentId === right.businessIntentId && + left.requestFingerprint === right.requestFingerprint && + left.network === right.network && + left.tokenContract.toLowerCase() === right.tokenContract.toLowerCase() && + left.recipient.toLowerCase() === right.recipient.toLowerCase() && + left.amountAtomic === right.amountAtomic + ); +} + +function stableDigest(value: unknown): string { + return sha256(JSON.stringify(value)); +} + +function held(...issues: RecoveryServiceIssueCode[]): RecoveryServiceResult { + return { status: 'HELD', issues: [...new Set(issues)], pack: null, externalSubmissionCount: 0 }; +} + +function sourceProvenance(record: BoundEvidenceRecord): RecoveryRecordProvenance { + if (record.source === 'THE_GRAPH') { + throw new Error('The Graph records require explicit MCP provenance'); + } + if (record.source === 'LLM') { + throw new Error('LLM records require explicit model provenance'); + } + return { + kind: 'SOURCE', + source: record.source, + sourceVersion: RECOVERY_EVIDENCE_VERSION, + }; +} + +function observationFromBoundRecord( + businessIntentId: string, + record: BoundEvidenceRecord, +): RecoveryObservationRecord { + const safe = { + source: record.source, + authorityClass: record.authorityClass, + retrievedAt: record.retrievedAt, + freshness: record.freshness ?? null, + blockNumber: record.blockNumber ?? null, + reason: sanitizeText(record.sanitizedReason ?? 'Observation accepted at recovery boundary'), + evidenceReferences: [record.id], + sourceDigest: record.digest, + }; + return { + schemaVersion: RECOVERY_RECORD_VERSION, + recordType: 'OBSERVATION', + recordId: `observation:${stableDigest(safe)}`, + businessIntentId, + source: record.source as Exclude, + authorityClass: record.authorityClass, + retrievedAt: record.retrievedAt, + freshness: record.freshness ?? null, + blockNumber: record.blockNumber ?? null, + reason: safe.reason, + evidenceReferences: safe.evidenceReferences, + digest: stableDigest(safe), + provenance: sourceProvenance(record), + }; +} + +function graphObservation(businessIntentId: string, view: IndexView): RecoveryObservationRecord { + const reason = sanitizeText( + view.diagnostics.length === 0 + ? `Subgraph MCP observation accepted with ${view.candidateCount} candidate(s)` + : `Subgraph MCP diagnostics: ${view.diagnostics.join(', ')}`, + ); + const evidenceReferences = view.candidates.map((candidate) => `thegraph:${candidate.id}`); + const safe = { + source: 'THE_GRAPH', + authorityClass: view.source.authority, + retrievedAt: view.retrievedAt, + freshness: view.health, + blockNumber: view.observedThrough?.blockNumber ?? null, + reason, + evidenceReferences, + mcp: view.mcp, + }; + return { + schemaVersion: RECOVERY_RECORD_VERSION, + recordType: 'OBSERVATION', + recordId: `observation:${stableDigest(safe)}`, + businessIntentId, + source: 'THE_GRAPH', + authorityClass: 'NON_AUTHORITATIVE_CANDIDATE_DISCOVERY', + retrievedAt: view.retrievedAt, + freshness: view.health, + blockNumber: view.observedThrough?.blockNumber ?? null, + reason, + evidenceReferences, + digest: stableDigest(safe), + provenance: { + kind: 'MCP', + serverName: view.mcp.serverName, + serverVersion: view.mcp.serverVersion, + deploymentId: view.mcp.deploymentId, + manifestCid: view.mcp.manifestCid, + toolName: view.mcp.toolName, + queryName: view.mcp.queryName, + queryDigest: view.mcp.queryDigest, + }, + }; +} + +function decisionRecord( + businessIntentId: string, + requestedAt: string, + outcome: RecoveryRecommendationOutcome, + command: ReconciliationCommand, +): RecoveryDecisionRecord { + const recommendation = outcome.recommendation; + const reason = sanitizeText(command.reason); + const safe = { + advisoryAction: recommendation.action, + coreDisposition: command.commandType, + reason, + evidenceReferences: [...command.evidenceReferences], + modelIdentity: recommendation.modelIdentity, + }; + return { + schemaVersion: RECOVERY_RECORD_VERSION, + recordType: 'DECISION', + recordId: `decision:${stableDigest(safe)}`, + businessIntentId, + source: 'LLM', + authorityClass: 'ADVISORY_AGENT_OBSERVATION', + retrievedAt: requestedAt, + freshness: null, + blockNumber: null, + reason, + evidenceReferences: safe.evidenceReferences, + digest: stableDigest(safe), + provenance: { kind: 'MODEL', modelIdentity: recommendation.modelIdentity }, + advisoryAction: recommendation.action, + coreDisposition: command.commandType, + }; +} + +function buildCommandPack(params: { + job: RecoveryJob; + snapshot: LocalRecoverySnapshot; + evidence: KnownIdentityRecoveryEvidence; + indexView: IndexView | null; + recommendation: RecoveryRecommendationOutcome; +}): RecoveryCommandPack { + const evaluated = evaluateReconciliation({ + binding: params.snapshot.binding, + durable: params.snapshot.durable, + evidence: params.evidence, + indexView: params.indexView, + recommendationOutcome: params.recommendation, + evaluatedAt: params.job.requestedAt, + }); + const reconciliationCommand: ReconciliationCommand = { + ...evaluated.command, + reason: sanitizeText(evaluated.command.reason), + }; + const extracted = buildBoundEvidenceRecords( + params.snapshot.binding, + params.evidence, + params.indexView, + ); + const records: RecoveryRecord[] = extracted.records + .filter((record) => record.source !== 'THE_GRAPH' && record.source !== 'LLM') + .map((record) => observationFromBoundRecord(params.job.businessIntentId, record)); + + if (params.indexView !== null) { + records.push(graphObservation(params.job.businessIntentId, params.indexView)); + } + records.push( + decisionRecord( + params.job.businessIntentId, + params.job.requestedAt, + params.recommendation, + reconciliationCommand, + ), + ); + + const appendCommands = records.map((record): AppendRecoveryRecordCommand => ({ + schemaVersion: APPEND_RECOVERY_RECORD_VERSION, + commandId: `append:${stableDigest({ + eventId: params.job.eventId, + stateVersion: params.snapshot.durable.stateVersion, + recordId: record.recordId, + })}`, + operation: 'APPEND_RECOVERY_RECORD', + businessIntentId: params.job.businessIntentId, + expectedStateVersion: params.snapshot.durable.stateVersion, + record, + })); + const packIdentity = { + eventId: params.job.eventId, + businessIntentId: params.job.businessIntentId, + sourceStateVersion: params.snapshot.durable.stateVersion, + commandIds: appendCommands.map((command) => command.commandId), + reconciliationCommand, + }; + + return { + schemaVersion: RECOVERY_COMMAND_PACK_VERSION, + packId: `recovery-pack:${stableDigest(packIdentity)}`, + eventId: params.job.eventId, + businessIntentId: params.job.businessIntentId, + sourceStateVersion: params.snapshot.durable.stateVersion, + generatedAt: params.job.requestedAt, + appendCommands, + reconciliationCommand, + recoveryView: evaluated.view, + externalSubmissionCount: 0, + }; +} + +export class RecoveryService { + constructor(private readonly ports: RecoveryServicePorts) {} + + async handle(job: RecoveryJob): Promise { + if ( + job.schemaVersion !== RECOVERY_JOB_VERSION || + job.eventId.length === 0 || + job.businessIntentId.length === 0 || + !Number.isFinite(Date.parse(job.requestedAt)) + ) { + return held('INVALID_JOB'); + } + + let existing: RecoveryCommandPack | null; + try { + existing = await this.ports.commandStore.findByEventId(job.eventId); + } catch { + return held('COMMAND_STORE_UNAVAILABLE'); + } + if (existing !== null) { + if (existing.schemaVersion !== RECOVERY_COMMAND_PACK_VERSION) { + return held('CONTRACT_VERSION_MISMATCH'); + } + if (existing.businessIntentId !== job.businessIntentId) { + return held('EVENT_ID_CONFLICT'); + } + return { + status: 'DUPLICATE', + issues: ['DUPLICATE_EVENT'], + pack: existing, + externalSubmissionCount: 0, + }; + } + + let snapshot: LocalRecoverySnapshot; + try { + snapshot = await this.ports.localState.read(job.businessIntentId); + } catch { + return held('LOCAL_STATE_UNAVAILABLE'); + } + if ( + snapshot.schemaVersion !== LOCAL_RECOVERY_SNAPSHOT_VERSION || + snapshot.binding.businessIntentId !== job.businessIntentId + ) { + return held('CONTRACT_VERSION_MISMATCH'); + } + if (hasForbiddenPayload(snapshot)) { + return held('RAW_PROVIDER_PAYLOAD_REJECTED'); + } + if (!sameBinding(snapshot.binding, snapshot.indexRequest.binding)) { + return held('EVIDENCE_BINDING_MISMATCH'); + } + + let evidence: KnownIdentityRecoveryEvidence; + try { + evidence = await this.ports.knownIdentityEvidence.read(snapshot.binding); + } catch { + return held('EVIDENCE_UNAVAILABLE'); + } + if (evidence.schemaVersion !== RECOVERY_EVIDENCE_VERSION) { + return held('CONTRACT_VERSION_MISMATCH'); + } + if (!sameBinding(snapshot.binding, evidence.binding)) { + return held('EVIDENCE_BINDING_MISMATCH'); + } + if (hasForbiddenPayload(evidence)) { + return held('RAW_PROVIDER_PAYLOAD_REJECTED'); + } + + const issues: RecoveryServiceIssueCode[] = []; + let indexView: IndexView | null = null; + try { + const indexOutcome = await this.ports.subgraphMcp.lookup( + snapshot.indexRequest, + snapshot.mcpPolicy, + ); + if (indexOutcome.view.schemaVersion !== INDEX_VIEW_VERSION) { + return held('CONTRACT_VERSION_MISMATCH'); + } + if (!sameBinding(snapshot.binding, indexOutcome.view.binding)) { + return held('EVIDENCE_BINDING_MISMATCH'); + } + indexView = indexOutcome.view; + if (!indexOutcome.accepted) issues.push('MCP_BOUNDARY_REJECTED'); + if (indexView.health === 'UNKNOWN_FRESHNESS') { + issues.push('MISSING_FRESHNESS_METADATA'); + } + } catch { + issues.push('MCP_UNAVAILABLE'); + } + + const input = buildRecoveryAgentInput({ + binding: snapshot.binding, + durableState: snapshot.durable, + evidence, + indexView, + }); + const evidenceIds = [ + ...input.authoritativeEvidence.map((record) => record.id), + ...input.providerObservations.map((record) => record.id), + ...input.candidateObservations.map((candidate) => `thegraph:${candidate.id}`), + ]; + + let rawAdvisorOutcome: RecoveryRecommendationOutcome | null = null; + try { + rawAdvisorOutcome = await this.ports.advisor.recommend(input); + } catch { + issues.push('ADVISOR_UNAVAILABLE'); + } + const normalized = validateAndNormalizeRecommendation( + rawAdvisorOutcome?.recommendation ?? null, + snapshot.binding, + evidenceIds, + () => job.requestedAt, + ); + const recommendation: RecoveryRecommendationOutcome = + rawAdvisorOutcome?.accepted === true && normalized.accepted + ? normalized + : { + ...normalized, + accepted: false, + issues: + rawAdvisorOutcome?.issues && rawAdvisorOutcome.issues.length > 0 + ? rawAdvisorOutcome.issues + : normalized.issues, + }; + if (!recommendation.accepted) issues.push('ADVISOR_BOUNDARY_REJECTED'); + + const pack = buildCommandPack({ job, snapshot, evidence, indexView, recommendation }); + let stored: RecoveryCommandStoreResult; + try { + stored = await this.ports.commandStore.append(pack); + } catch { + return held('COMMAND_STORE_UNAVAILABLE'); + } + if (stored.status === 'DUPLICATE') { + if ( + stored.pack.businessIntentId !== job.businessIntentId || + stored.pack.packId !== pack.packId + ) { + return held('EVENT_ID_CONFLICT'); + } + return { + status: 'DUPLICATE', + issues: [...new Set([...issues, 'DUPLICATE_EVENT'])], + pack: stored.pack, + externalSubmissionCount: 0, + }; + } + return { + status: 'PROCESSED', + issues: [...new Set(issues)], + pack: stored.pack, + externalSubmissionCount: 0, + }; + } +} diff --git a/packages/reconciliation/test/service-integration.test.ts b/packages/reconciliation/test/service-integration.test.ts new file mode 100644 index 0000000..fe72b75 --- /dev/null +++ b/packages/reconciliation/test/service-integration.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest'; +import { + InMemoryRecoveryCommandStore, + RecoveryService, + createRecoverySimulatorComposition, + runRecoveryMatrix, + type KnownIdentityEvidencePort, + type LocalRecoveryStatePort, + type RecoveryAdvisorPort, + type RecoveryCommandStorePort, +} from '../src/index.js'; + +describe('C04 recovery service boundary', () => { + it('composes all source simulators and emits a sanitized append-only command pack', async () => { + const composition = createRecoverySimulatorComposition({ + agentScenario: 'return-existing-result', + }); + + const result = await composition.service.handle(composition.job); + + expect(result.status).toBe('PROCESSED'); + expect(result.externalSubmissionCount).toBe(0); + expect(result.pack?.reconciliationCommand.commandType).toBe('MARK_COMMITTED'); + expect(result.pack?.reconciliationCommand.settlementPermission).toBe('NEVER'); + expect(result.pack?.appendCommands.length).toBeGreaterThanOrEqual(4); + + const records = result.pack?.appendCommands.map((command) => command.record) ?? []; + const graph = records.find((record) => record.source === 'THE_GRAPH'); + const decision = records.find((record) => record.recordType === 'DECISION'); + + expect(graph?.authorityClass).toBe('NON_AUTHORITATIVE_CANDIDATE_DISCOVERY'); + expect(graph?.freshness).toBe('FRESH'); + expect(graph?.provenance.kind).toBe('MCP'); + expect(decision?.authorityClass).toBe('ADVISORY_AGENT_OBSERVATION'); + expect(decision?.provenance.kind).toBe('MODEL'); + expect(JSON.stringify(result.pack)).not.toMatch(/rawProviderBody|authorization|Bearer /iu); + }); + + it.each([ + ['wait', 'HOLD_UNKNOWN'], + ['reconcile', 'READ_ONLY_LOOKUP'], + ['escalate', 'ESCALATE_UNKNOWN'], + ['return-existing-result', 'HOLD_UNKNOWN'], + ] as const)( + 'maps advisor action %s through the deterministic core', + async (agentScenario, expected) => { + const composition = createRecoverySimulatorComposition({ + agentScenario, + withArcProof: false, + eventId: `event-action-${agentScenario}`, + }); + + const result = await composition.service.handle(composition.job); + + expect(result.pack?.reconciliationCommand.commandType).toBe(expected); + expect(result.pack?.reconciliationCommand.targetState).toBe('UNKNOWN'); + expect(result.externalSubmissionCount).toBe(0); + }, + ); + + it('deduplicates repeated event delivery before rereading external evidence', async () => { + const composition = createRecoverySimulatorComposition({ withArcProof: false }); + + const first = await composition.service.handle(composition.job); + const second = await composition.service.handle(composition.job); + + expect(first.status).toBe('PROCESSED'); + expect(second.status).toBe('DUPLICATE'); + expect(second.issues).toContain('DUPLICATE_EVENT'); + expect(second.pack?.packId).toBe(first.pack?.packId); + expect(composition.commandStore.size).toBe(1); + expect(composition.localState.readCount).toBe(1); + expect(composition.knownIdentityEvidence.readCount).toBe(1); + expect(composition.subgraphMcp.lookupCount).toBe(1); + }); + + it('rejects reuse of one event ID for a different business intent', async () => { + const store = new InMemoryRecoveryCommandStore(); + const first = createRecoverySimulatorComposition({ + businessIntentId: 'intent-event-owner', + eventId: 'shared-event-id', + commandStore: store, + }); + const conflicting = createRecoverySimulatorComposition({ + businessIntentId: 'intent-event-conflict', + eventId: 'shared-event-id', + commandStore: store, + }); + + expect((await first.service.handle(first.job)).status).toBe('PROCESSED'); + const result = await conflicting.service.handle(conflicting.job); + + expect(result.status).toBe('HELD'); + expect(result.issues).toEqual(['EVENT_ID_CONFLICT']); + expect(result.pack).toBeNull(); + expect(store.size).toBe(1); + }); + + it('converges ten concurrent handlers on one immutable command pack', async () => { + const composition = createRecoverySimulatorComposition({ + businessIntentId: 'intent-c04-concurrent', + eventId: 'event-c04-concurrent', + withArcProof: false, + }); + + const results = await Promise.all( + Array.from({ length: 10 }, () => composition.service.handle(composition.job)), + ); + + expect(composition.commandStore.size).toBe(1); + expect(new Set(results.map((result) => result.pack?.packId)).size).toBe(1); + expect(results.filter((result) => result.status === 'PROCESSED')).toHaveLength(1); + expect(results.every((result) => result.externalSubmissionCount === 0)).toBe(true); + }); + + it('fails closed on local contract mismatch without calling other sources', async () => { + const composition = createRecoverySimulatorComposition(); + const localState = { + read: async () => ({ ...composition.localState.snapshot, schemaVersion: 'future-v2' }), + } as unknown as LocalRecoveryStatePort; + const service = new RecoveryService({ + localState, + knownIdentityEvidence: composition.knownIdentityEvidence, + subgraphMcp: composition.subgraphMcp, + advisor: composition.advisor, + commandStore: new InMemoryRecoveryCommandStore(), + }); + + const result = await service.handle(composition.job); + + expect(result.status).toBe('HELD'); + expect(result.issues).toEqual(['CONTRACT_VERSION_MISMATCH']); + expect(result.pack).toBeNull(); + expect(composition.knownIdentityEvidence.readCount).toBe(0); + expect(result.externalSubmissionCount).toBe(0); + }); + + it('rejects a Graph request that is not bound to the local snapshot', async () => { + const composition = createRecoverySimulatorComposition(); + const localState = { + read: async () => ({ + ...composition.localState.snapshot, + indexRequest: { + ...composition.localState.snapshot.indexRequest, + binding: { + ...composition.localState.snapshot.binding, + amountAtomic: '999999999', + }, + }, + }), + } as LocalRecoveryStatePort; + const service = new RecoveryService({ + localState, + knownIdentityEvidence: composition.knownIdentityEvidence, + subgraphMcp: composition.subgraphMcp, + advisor: composition.advisor, + commandStore: new InMemoryRecoveryCommandStore(), + }); + + const result = await service.handle(composition.job); + + expect(result.status).toBe('HELD'); + expect(result.issues).toEqual(['EVIDENCE_BINDING_MISMATCH']); + expect(composition.subgraphMcp.lookupCount).toBe(0); + }); + + it('fails closed when the append-only command store is unavailable', async () => { + const composition = createRecoverySimulatorComposition(); + const commandStore = { + findByEventId: async () => null, + append: async () => { + throw new Error('store unavailable'); + }, + } satisfies RecoveryCommandStorePort; + const service = new RecoveryService({ + localState: composition.localState, + knownIdentityEvidence: composition.knownIdentityEvidence, + subgraphMcp: composition.subgraphMcp, + advisor: composition.advisor, + commandStore, + }); + + const result = await service.handle(composition.job); + + expect(result.status).toBe('HELD'); + expect(result.issues).toEqual(['COMMAND_STORE_UNAVAILABLE']); + expect(result.pack).toBeNull(); + expect(result.externalSubmissionCount).toBe(0); + }); + + it('rejects raw provider payloads before they cross the persistence seam', async () => { + const composition = createRecoverySimulatorComposition(); + const knownIdentityEvidence = { + read: async () => ({ + ...composition.knownIdentityEvidence.evidence, + rawProviderBody: 'Bearer should-never-cross-this-boundary', + }), + } as unknown as KnownIdentityEvidencePort; + const store = new InMemoryRecoveryCommandStore(); + const service = new RecoveryService({ + localState: composition.localState, + knownIdentityEvidence, + subgraphMcp: composition.subgraphMcp, + advisor: composition.advisor, + commandStore: store, + }); + + const result = await service.handle(composition.job); + + expect(result.status).toBe('HELD'); + expect(result.issues).toContain('RAW_PROVIDER_PAYLOAD_REJECTED'); + expect(store.size).toBe(0); + expect(result.externalSubmissionCount).toBe(0); + }); + + it('rejects an unknown agent enum and holds UNKNOWN', async () => { + const composition = createRecoverySimulatorComposition({ withArcProof: false }); + const advisor = { + recommend: async () => ({ + accepted: true, + recommendation: { + action: 'RETRY_SETTLEMENT', + decisionId: 'unsafe-decision', + reason: 'retry', + referencedEvidenceIds: [], + modelIdentity: { + modelName: 'unsafe-simulator', + modelVersion: '1', + promptVersion: '1', + }, + timestamp: composition.job.requestedAt, + }, + issues: [], + }), + } as unknown as RecoveryAdvisorPort; + const service = new RecoveryService({ + localState: composition.localState, + knownIdentityEvidence: composition.knownIdentityEvidence, + subgraphMcp: composition.subgraphMcp, + advisor, + commandStore: new InMemoryRecoveryCommandStore(), + }); + + const result = await service.handle(composition.job); + + expect(result.issues).toContain('ADVISOR_BOUNDARY_REJECTED'); + expect(result.pack?.reconciliationCommand.commandType).toBe('HOLD_UNKNOWN'); + expect(result.pack?.reconciliationCommand.advisoryAction).toBe('WAIT'); + expect(result.externalSubmissionCount).toBe(0); + }); + + it('fails closed on missing freshness and wrong MCP identity', async () => { + for (const mcpScenario of ['unknown-freshness', 'wrong-tool', 'wrong-deployment'] as const) { + const composition = createRecoverySimulatorComposition({ + businessIntentId: `intent-${mcpScenario}`, + eventId: `event-${mcpScenario}`, + mcpScenario, + withArcProof: false, + agentScenario: 'wait', + }); + const result = await composition.service.handle(composition.job); + + expect(result.pack?.reconciliationCommand.targetState).toBe('UNKNOWN'); + expect(result.pack?.reconciliationCommand.settlementPermission).toBe('NEVER'); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.externalSubmissionCount).toBe(0); + } + }); +}); + +describe('C04 complete pre-live recovery matrix', () => { + it('covers required recovery seams with zero external submissions', async () => { + const rows = await runRecoveryMatrix(); + const scenarioIds = rows.map((row) => row.scenario); + + expect(rows.length).toBeGreaterThanOrEqual(35); + expect(scenarioIds).toEqual( + expect.arrayContaining([ + 'normal-authoritative-success', + 'advisor-wait', + 'advisor-reconcile', + 'advisor-escalate', + 'advisor-return-existing-result', + 'invalid-model-output', + 'duplicate-delivery', + 'ten-concurrent-recovery-workers', + 'crash-before-submission', + 'lost-response-after-submission', + 'mcp-delayed-result', + 'mcp-query-failure', + 'mcp-malformed-result', + 'mcp-hostile-injection', + 'privy-policy-denial', + 'restart-between-transitions', + 'downstream-failure-after-payment', + 'two-agent-instances', + ]), + ); + expect(rows.every((row) => row.externalSubmissionCount === 0)).toBe(true); + expect(rows.every((row) => row.passed)).toBe(true); + }); +});