From 8ed609626227cc5464b1f0d883c2a820d8ba053d Mon Sep 17 00:00:00 2001 From: Matvii Nesterenko <51422901+kapustazh@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:55:23 +0200 Subject: [PATCH] fix: require recovery lookup config --- ...0908T224457Z-production-recovery-config.md | 81 +++++++++++++++++ apps/worker/src/composition.ts | 26 +++--- apps/worker/src/recovery-bridge.ts | 37 ++++---- apps/worker/test/p4-composition.test.ts | 87 +++++++++++++++---- packages/reconciliation/src/index.ts | 6 +- packages/reconciliation/src/validation.ts | 15 ++-- 6 files changed, 195 insertions(+), 57 deletions(-) create mode 100644 .agent/context/20260908T224457Z-production-recovery-config.md diff --git a/.agent/context/20260908T224457Z-production-recovery-config.md b/.agent/context/20260908T224457Z-production-recovery-config.md new file mode 100644 index 0000000..b40c918 --- /dev/null +++ b/.agent/context/20260908T224457Z-production-recovery-config.md @@ -0,0 +1,81 @@ +# Session Context: production recovery configuration + +## Date/time + +- UTC: 2026-09-08T22:44:57Z + +## User goal + +Fix blockers left by earlier plans before continuing future work, using provider access already held in Google Cloud, Privy, and The Graph. + +## Original prompt/request + +"let's fix current issues that we have from previous plans, after contining future work. all of api's i have in google cloud, privy, the graph and etc." + +## Assumptions + +- Credentials and API secrets remain outside Git and review prompts. +- The current packet fixes the production recovery configuration boundary before any live MCP/model adapter or evidence claim. +- A single configured sender and bounded block window are sufficient for the current live-value gate; per-intent window derivation is future work if multi-intent production recovery needs it. + +## Plan + +1. Remove placeholder Graph identity and unbounded recovery correlation values. +2. Require explicit production recovery lookup configuration and reject invalid input before MCP lookup. +3. Run repository checks and mandatory FreePi review gates, then open a draft PR. + +## Key decisions + +- Reused reconciliation's existing validation predicates through one exported boolean; no duplicate validator and no new dependency. +- Kept Subgraph MCP and advisor unavailable by default. Live admission still requires the recorded C01 promotion evidence. +- Used an options object for production recovery composition so provider ports and lookup configuration cannot be positionally confused. + +## Files/components touched + +- `packages/reconciliation/src/validation.ts`: reusable lookup-input validation. +- `packages/reconciliation/src/index.ts`: public validation export. +- `apps/worker/src/recovery-bridge.ts`: explicit real lookup configuration; placeholder removal; fail-closed validation. +- `apps/worker/src/composition.ts`: required production recovery options. +- `apps/worker/test/p4-composition.test.ts`: valid identities, propagation, and placeholder rejection coverage. + +## Commands/checks + +- `pnpm --filter @oneshot/reconciliation typecheck` - PASS; local Node 22 warning against pinned Node 24.19.0. +- `pnpm --filter @oneshot/worker typecheck` - PASS; same engine warning. +- `pnpm --filter @oneshot/worker test -- --run test/p4-composition.test.ts` - PASS, 7 tests after fixture correction. +- `pnpm --filter @oneshot/reconciliation test` - PASS, 74 tests. +- `pnpm lint` - PASS. +- `pnpm typecheck` - PASS. +- `pnpm test` - PASS, 54 files and 868 tests; includes full build. +- `pnpm format:check` - FAIL only on two pre-existing generated subgraph files; all five touched TypeScript files pass targeted Prettier check. +- `git diff --check` - PASS. + +## External-doc findings + +- The Graph official `graphops/subgraph-mcp` documentation confirms immutable queries use `execute_query_by_deployment_id` with deployment ID, query, and variables. +- MCP 2025-11-25 schema confirms `tools/call` is JSON-RPC 2.0 with a tool name and arguments. +- Google Cloud Vertex AI documentation confirms REST `generateContent` bearer authentication and JSON structured output support. No adapter is admitted in this packet. + +## Unresolved questions + +- Canonical live deployment ID, manifest CID, MCP endpoint/version, sender, and bounded Arc block window still need retrieval from operator-controlled systems. +- Live Subgraph MCP and Vertex AI model traces remain required before `SELECT_SUBGRAPH_MCP` or sponsor qualification. + +## Git and PR state + +- Branch: `fix/production-recovery-config` +- Base: `origin/develop` at `48391e4968675764632627716e580988a271c13d` +- Commit: uncommitted +- PR: not created +- CI: not run + +## Review gates + +- Gate A: NOT RUN +- Gate B: NOT RUN + +## Handoff/next steps + +1. Finish local checks and Gate A. +2. Commit, push, open draft PR, wait for exact-head CI, and run Gate B. +3. After human merge, retrieve non-secret live identities and implement/admit the minimal MCP/model adapters only with live evidence. diff --git a/apps/worker/src/composition.ts b/apps/worker/src/composition.ts index ed149ef..10a275e 100644 --- a/apps/worker/src/composition.ts +++ b/apps/worker/src/composition.ts @@ -25,26 +25,32 @@ import { IntentLedgerLocalRecoveryStatePort, IntentLedgerRecoveryCommandStore, PrivyArcEvidenceBridge, + type IntentLedgerLocalRecoveryStatePortOptions, type PrivyArcEvidenceBridgeOptions, } from './recovery-bridge.js'; export const CURRENT_CONTRACT_VERSION = '1.0.0'; export const SUPPORTED_NETWORK = 'eip155:5042002'; +export interface ProductionRecoveryServiceOptions { + readonly localState: IntentLedgerLocalRecoveryStatePortOptions; + readonly bridge?: PrivyArcEvidenceBridgeOptions; + readonly subgraphMcp?: SubgraphMcpRecoveryPort; + readonly advisor?: RecoveryAdvisorPort; +} + export function createProductionRecoveryService( ledger: IntentLedger, - bridgeOptions?: PrivyArcEvidenceBridgeOptions, - subgraphMcpPort?: SubgraphMcpRecoveryPort, - advisor?: RecoveryAdvisorPort, + options: ProductionRecoveryServiceOptions, ): RecoveryService { - const localState = new IntentLedgerLocalRecoveryStatePort(ledger); + const localState = new IntentLedgerLocalRecoveryStatePort(ledger, options.localState); const commandStore = new IntentLedgerRecoveryCommandStore(ledger); const knownIdentityEvidence = new PrivyArcEvidenceBridge({ localStatePort: localState, - ...bridgeOptions, + ...options.bridge, }); - const subgraphMcp = subgraphMcpPort ?? new UnavailableSubgraphMcpRecoveryPort(); - const recoveryAdvisor = advisor ?? new UnavailableRecoveryAdvisorPort(); + const subgraphMcp = options.subgraphMcp ?? new UnavailableSubgraphMcpRecoveryPort(); + const recoveryAdvisor = options.advisor ?? new UnavailableRecoveryAdvisorPort(); return new RecoveryService({ localState, knownIdentityEvidence, @@ -109,7 +115,7 @@ export interface CompositionOptions { readonly contractVersion?: string; }; readonly recoveryService?: RecoveryService; - readonly recoveryBridgeOptions?: PrivyArcEvidenceBridgeOptions; + readonly recovery?: ProductionRecoveryServiceOptions; readonly submissionsDisabled?: boolean; readonly expectedContractVersion?: string; readonly expectedNetwork?: string; @@ -143,8 +149,8 @@ export function composeWorker( } let recoveryService = options.recoveryService; - if (!recoveryService && options.profile === 'production' && options.recoveryBridgeOptions) { - recoveryService = createProductionRecoveryService(ledger, options.recoveryBridgeOptions); + if (!recoveryService && options.profile === 'production' && options.recovery) { + recoveryService = createProductionRecoveryService(ledger, options.recovery); } const workerOptions: WorkerOptions = { diff --git a/apps/worker/src/recovery-bridge.ts b/apps/worker/src/recovery-bridge.ts index c391d57..e934ab9 100644 --- a/apps/worker/src/recovery-bridge.ts +++ b/apps/worker/src/recovery-bridge.ts @@ -8,6 +8,7 @@ import { import type { IntentLedger } from '@oneshot/storage-postgres'; import { APPEND_RECOVERY_RECORD_VERSION, + isValidSubgraphLookupInput, LOCAL_RECOVERY_SNAPSHOT_VERSION, RECOVERY_EVIDENCE_VERSION, type EvidenceBinding, @@ -56,8 +57,11 @@ function toContractAuthorityClass(authClass: string): 'AUTHORITATIVE' | 'OBSERVA } export interface IntentLedgerLocalRecoveryStatePortOptions { - readonly tokenContract?: string; - readonly correlationSender?: string; + readonly tokenContract: string; + readonly correlationSender: string; + readonly fromBlock: string; + readonly toBlock: string; + readonly mcpPolicy: SubgraphMcpPolicy; } /** @@ -66,7 +70,7 @@ export interface IntentLedgerLocalRecoveryStatePortOptions { export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePort { constructor( private readonly ledger: IntentLedger, - private readonly options: IntentLedgerLocalRecoveryStatePortOptions = {}, + private readonly options: IntentLedgerLocalRecoveryStatePortOptions, ) {} async read(businessIntentId: string): Promise { @@ -75,16 +79,11 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor throw new Error(`Intent not found: ${businessIntentId}`); } - const tokenContract = - this.options.tokenContract ?? - (intent.attempts?.[0] as { token_contract?: string } | undefined)?.token_contract ?? - '0x3333333333333333333333333333333333333333'; - const binding: EvidenceBinding = { businessIntentId: intent.business_intent_id, requestFingerprint: intent.payload_fingerprint, network: intent.network, - tokenContract, + tokenContract: this.options.tokenContract, recipient: intent.recipient, amountAtomic: intent.amount_atomic, }; @@ -96,21 +95,15 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor binding, correlation: { strategy: 'TRANSFER_TUPLE_WINDOW', - sender: '0x2222222222222222222222222222222222222222', - fromBlock: '0', - toBlock: 'latest', + sender: this.options.correlationSender, + fromBlock: this.options.fromBlock, + toBlock: this.options.toBlock, }, }; - const mcpPolicy: SubgraphMcpPolicy = { - serverName: 'subgraph-mcp', - serverVersion: '1.0.0', - deploymentId: 'oneshot-arc-testnet', - manifestCid: 'QmOneShotArcTestnetManifest', - maxLagBlocks: '50', - maxCandidates: 5, - maxResultBytes: 65536, - }; + if (!isValidSubgraphLookupInput(indexRequest, this.options.mcpPolicy)) { + throw new Error('Invalid Subgraph MCP recovery lookup input'); + } return { schemaVersion: LOCAL_RECOVERY_SNAPSHOT_VERSION, @@ -122,7 +115,7 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor persistedAt: nowIso, }, indexRequest, - mcpPolicy, + mcpPolicy: this.options.mcpPolicy, capturedAt: nowIso, }; } diff --git a/apps/worker/test/p4-composition.test.ts b/apps/worker/test/p4-composition.test.ts index 3bc4477..760d6cc 100644 --- a/apps/worker/test/p4-composition.test.ts +++ b/apps/worker/test/p4-composition.test.ts @@ -20,6 +20,7 @@ import { } from '@oneshot/privy-adapter'; import { createRecoverySimulatorComposition, + RecoveryService, type DetailedRecoveryView, } from '@oneshot/reconciliation'; import { composeWorker, createProductionRecoveryService } from '../src/composition.js'; @@ -59,6 +60,22 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { const realTxHash = '0x' + 'e'.repeat(64); const realBlockHash = '0x' + 'b'.repeat(64); const realSender = '0x2222222222222222222222222222222222222222'; + const requestFingerprint = 'f'.repeat(64); + const recoveryLocalState = { + tokenContract: sampleConfig.usdcContract, + correlationSender: realSender, + fromBlock: '999000', + toBlock: '999200', + mcpPolicy: { + serverName: 'subgraph-mcp', + serverVersion: '1.0.0', + deploymentId: `0x${'d'.repeat(64)}`, + manifestCid: `Qm${'a'.repeat(44)}`, + maxLagBlocks: '5', + maxCandidates: 5, + maxResultBytes: 65536, + }, + } as const; const realReceipt: TransactionReceipt = { transactionHash: realTxHash, @@ -104,29 +121,30 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { () => sampleBaseline, ); - const productionRecoveryService = createProductionRecoveryService(mockLedger, { - defaultArcTxHash: realTxHash, - defaultReceipt: realReceipt, - }); - const composed = composeWorker(mockPool, mockLedger, { profile: 'production', settlementPort: settlementAdapter, authorizationPort: authorizationAdapter, - recoveryService: productionRecoveryService, + recovery: { + localState: recoveryLocalState, + bridge: { + defaultArcTxHash: realTxHash, + defaultReceipt: realReceipt, + }, + }, }); const readiness = await composed.checkReadiness(); expect(readiness.ready).toBe(true); expect(composed.options.settlementPort).toBe(settlementAdapter); expect(composed.options.authorizationPort).toBe(authorizationAdapter); - expect(composed.options.recoveryService).toBe(productionRecoveryService); + expect(composed.options.recoveryService).toBeInstanceOf(RecoveryService); }); it('IntentLedgerLocalRecoveryStatePort produces valid snapshot from IntentLedger', async () => { const mockIntent: IntentResponse = { ...sampleRequest, - payload_fingerprint: 'fp-p4-1', + payload_fingerprint: requestFingerprint, state: 'UNKNOWN', version: 2, attempts: [ @@ -144,7 +162,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { id === mockIntent.business_intent_id ? mockIntent : undefined, } as unknown as IntentLedger; - const port = new IntentLedgerLocalRecoveryStatePort(mockLedger); + const port = new IntentLedgerLocalRecoveryStatePort(mockLedger, recoveryLocalState); const snapshot = await port.read('intent-p4-1'); expect(snapshot.schemaVersion).toBe('local-recovery-snapshot-v1'); @@ -153,6 +171,34 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { expect(snapshot.durable.state).toBe('UNKNOWN'); expect(snapshot.durable.stateVersion).toBe('2'); expect(snapshot.durable.attemptCount).toBe(1); + expect(snapshot.indexRequest.correlation).toEqual({ + strategy: 'TRANSFER_TUPLE_WINDOW', + sender: realSender, + fromBlock: '999000', + toBlock: '999200', + }); + expect(snapshot.mcpPolicy).toEqual(recoveryLocalState.mcpPolicy); + }); + + it('rejects placeholder recovery lookup identity before an MCP call', async () => { + const mockLedger = { + getIntent: async () => ({ + ...sampleRequest, + payload_fingerprint: requestFingerprint, + state: 'UNKNOWN', + version: 2, + attempts: [], + evidence: [], + }), + } as unknown as IntentLedger; + const port = new IntentLedgerLocalRecoveryStatePort(mockLedger, { + ...recoveryLocalState, + mcpPolicy: { ...recoveryLocalState.mcpPolicy, deploymentId: 'oneshot-arc-testnet' }, + }); + + await expect(port.read('intent-p4-1')).rejects.toThrow( + 'Invalid Subgraph MCP recovery lookup input', + ); }); it('IntentLedgerRecoveryCommandStore enforces durable deduplication, real CAS transitions, and fails closed', async () => { @@ -163,7 +209,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { const mockIntent: IntentResponse = { ...sampleRequest, - payload_fingerprint: 'fp-p4-1', + payload_fingerprint: requestFingerprint, get state() { return currentState; }, @@ -238,7 +284,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { authorityClass: 'AUTHORITATIVE_CHAIN_EVIDENCE' as const, binding: { businessIntentId: 'intent-p4-1', - requestFingerprint: 'fp-p4-1', + requestFingerprint, network: 'eip155:5042002', tokenContract: '0x0000000000000000000000000000000000000000', recipient: sampleRequest.recipient, @@ -254,7 +300,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { schemaVersion: 'reconciliation-command-v1' as const, commandType: 'MARK_COMMITTED' as const, businessIntentId: 'intent-p4-1', - requestFingerprint: 'fp-p4-1', + requestFingerprint, targetState: 'COMMITTED' as const, reason: 'Arc proof verified', evidenceReferences: ['arc:0x' + 'e'.repeat(64)], @@ -318,7 +364,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { const binding = { businessIntentId: 'intent-p4-1', - requestFingerprint: 'fp-p4-1', + requestFingerprint, network: 'eip155:5042002', tokenContract: '0x3333333333333333333333333333333333333333', recipient: sampleRequest.recipient, @@ -350,7 +396,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { const mockIntent: IntentResponse = { ...sampleRequest, - payload_fingerprint: 'fp-p4-1', + payload_fingerprint: requestFingerprint, get state() { return ledgerState; }, @@ -389,11 +435,14 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { }; const recoveryService = createProductionRecoveryService(mockLedger, { - evidencePort: mockEvidencePort as unknown as LaneBEvidencePort, - receiptSource: { - getReceipt: async () => realReceipt, + localState: recoveryLocalState, + bridge: { + evidencePort: mockEvidencePort as unknown as LaneBEvidencePort, + receiptSource: { + getReceipt: async () => realReceipt, + }, + defaultArcTxHash: realTxHash, }, - defaultArcTxHash: realTxHash, }); const job = { @@ -418,7 +467,7 @@ describe('Gate P4: Backend Convergence and Adapter Replacement', () => { const mockIntent: IntentResponse = { ...sampleRequest, - payload_fingerprint: 'fp-p4-1', + payload_fingerprint: requestFingerprint, get state() { return ledgerState; }, diff --git a/packages/reconciliation/src/index.ts b/packages/reconciliation/src/index.ts index 391c4fc..8c497d2 100644 --- a/packages/reconciliation/src/index.ts +++ b/packages/reconciliation/src/index.ts @@ -6,7 +6,11 @@ export { RECOVERY_CANDIDATE_QUERY_DIGEST, sha256, } from './query.js'; -export { normalizeSubgraphMcpTrace, validateKnownIdentityEvidence } from './validation.js'; +export { + isValidSubgraphLookupInput, + normalizeSubgraphMcpTrace, + validateKnownIdentityEvidence, +} from './validation.js'; export { CONTRACT_VERSIONS, createKnownIdentityFixture, diff --git a/packages/reconciliation/src/validation.ts b/packages/reconciliation/src/validation.ts index ec86402..22266c7 100644 --- a/packages/reconciliation/src/validation.ts +++ b/packages/reconciliation/src/validation.ts @@ -151,6 +151,15 @@ function validPolicy(policy: SubgraphMcpPolicy): boolean { ); } +export function isValidSubgraphLookupInput( + request: IndexLookupRequest, + policy: SubgraphMcpPolicy, +): boolean { + return ( + validBinding(request.binding) && validCorrelation(request.correlation) && validPolicy(policy) + ); +} + function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; if (isRecord(value)) { @@ -394,11 +403,7 @@ export function normalizeSubgraphMcpTrace( policy: SubgraphMcpPolicy, trace: SubgraphMcpTrace, ): IndexLookupOutcome { - if ( - !validBinding(request.binding) || - !validCorrelation(request.correlation) || - !validPolicy(policy) - ) { + if (!isValidSubgraphLookupInput(request, policy)) { return rejected( request, policy,