Synthetic review fixtures. Not live settlement evidence.
@@ -56,6 +58,6 @@ export function DemoShell({ initialScenario }: DemoShellProps) {
client={client}
allowedExplorerHosts={FIXTURE_EXPLORER_HOSTS}
/>
- >
+
);
}
diff --git a/packages/settlement-ui/src/SettlementDetailsRoute.tsx b/packages/settlement-ui/src/SettlementDetailsRoute.tsx
index c362d6f..bb8b391 100644
--- a/packages/settlement-ui/src/SettlementDetailsRoute.tsx
+++ b/packages/settlement-ui/src/SettlementDetailsRoute.tsx
@@ -101,24 +101,25 @@ export function SettlementDetailsRoute({
};
}, [businessIntentId, client, allowedExplorerHosts]);
+ let content;
if (state.kind === 'LOADING') {
- return (
+ content = (
ONESHOT / AUTHORIZATION AND SETTLEMENT
Loading settlement details…
);
- }
-
- if (state.kind === 'FAILED') {
- return (
+ } else if (state.kind === 'FAILED') {
+ content = (
ONESHOT / AUTHORIZATION AND SETTLEMENT
{state.heading}
{state.detail}
);
+ } else {
+ content =
;
}
- return
;
+ return
{content}
;
}
diff --git a/packages/settlement-ui/src/styles.css b/packages/settlement-ui/src/styles.css
index db9750f..86b91b7 100644
--- a/packages/settlement-ui/src/styles.css
+++ b/packages/settlement-ui/src/styles.css
@@ -1,3 +1,4 @@
+@scope (.settlement-slice) {
.settlement-details,
.route-state {
--ink: #e8eef7;
@@ -301,3 +302,4 @@
align-items: flex-start;
}
}
+}
diff --git a/packages/storage-postgres/src/ledger.ts b/packages/storage-postgres/src/ledger.ts
index a8e93fe..1ce9e3b 100644
--- a/packages/storage-postgres/src/ledger.ts
+++ b/packages/storage-postgres/src/ledger.ts
@@ -2,7 +2,9 @@ import {
asAttemptId,
asBusinessIntentId,
asCorrelationId,
+ CORE_DISPOSITIONS,
ContractValidationError,
+ RECOVERY_ACTIONS,
type AttemptView,
type BusinessIntentId,
type EvidenceView,
@@ -78,6 +80,158 @@ interface IntentRow {
readonly version: number;
}
+function object(value: unknown): Record
| null {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function text(value: unknown, maxLength = 500): string | null {
+ return typeof value === 'string' && value.length > 0 && value.length <= maxLength ? value : null;
+}
+
+function texts(value: unknown): readonly string[] {
+ return Array.isArray(value)
+ ? value.flatMap((item) => {
+ const parsed = text(item, 128);
+ return parsed === null ? [] : [parsed];
+ })
+ : [];
+}
+
+function persistedRecovery(payload: unknown): {
+ readonly action?: RecoveryView['recommended_action'];
+ readonly details: Partial;
+} {
+ const pack = object(payload);
+ const view = object(pack?.['recoveryView']);
+ const command = object(pack?.['reconciliationCommand']);
+ const action = RECOVERY_ACTIONS.find((value) => value === view?.['recommendedAction']);
+ const core = CORE_DISPOSITIONS.find((value) => value === view?.['coreDisposition']);
+ if (!view || !command || !action || !core) return { details: {} };
+
+ const records = Array.isArray(pack?.['appendCommands'])
+ ? pack['appendCommands'].flatMap((entry) => {
+ const record = object(object(entry)?.['record']);
+ return record === null ? [] : [record];
+ })
+ : [];
+ const agentRecord = records.find(
+ (record) => record['recordType'] === 'DECISION' && record['source'] === 'LLM',
+ );
+ const model = object(object(agentRecord?.['provenance'])?.['modelIdentity']);
+ const agentReason = text(agentRecord?.['reason']);
+ const modelName = text(model?.['modelName'], 128);
+ const modelVersion = text(model?.['modelVersion'], 128);
+ const promptVersion = text(model?.['promptVersion'], 128);
+
+ const graphRecord = records.find(
+ (record) => record['recordType'] === 'OBSERVATION' && record['source'] === 'THE_GRAPH',
+ );
+ const mcp = object(graphRecord?.['provenance']);
+ const candidates = Array.isArray(view['indexedCandidates'])
+ ? view['indexedCandidates'].flatMap((value) => {
+ const candidate = object(value);
+ const candidateId = text(candidate?.['id'], 128);
+ const transactionHash = text(candidate?.['transactionHash'], 66);
+ const blockNumber = text(candidate?.['blockNumber'], 78);
+ const bindingStatus = candidate?.['bindingStatus'];
+ const contradictionCodes = texts(candidate?.['contradictionCodes']);
+ if (
+ !candidateId ||
+ !transactionHash?.match(/^0x[0-9a-fA-F]{64}$/u) ||
+ !blockNumber?.match(/^(0|[1-9][0-9]*)$/u) ||
+ (bindingStatus !== 'MATCH' && bindingStatus !== 'CONTRADICTORY')
+ ) {
+ return [];
+ }
+ const normalizedBindingStatus: 'MATCH' | 'CONTRADICTORY' = bindingStatus;
+ return [
+ {
+ candidate_id: candidateId,
+ transaction_hash: transactionHash,
+ block_number: blockNumber,
+ binding_status: normalizedBindingStatus,
+ contradiction_codes: contradictionCodes,
+ },
+ ];
+ })
+ : [];
+ const health =
+ view['indexHealth'] === 'FRESH' ||
+ view['indexHealth'] === 'LAGGING' ||
+ view['indexHealth'] === 'UNHEALTHY' ||
+ view['indexHealth'] === 'UNAVAILABLE' ||
+ view['indexHealth'] === 'UNKNOWN_FRESHNESS'
+ ? view['indexHealth']
+ : null;
+ const serverName = text(mcp?.['serverName'], 128);
+ const serverVersion = text(mcp?.['serverVersion'], 128);
+ const toolName = text(mcp?.['toolName'], 128);
+ const deploymentId = text(mcp?.['deploymentId'], 128);
+ const manifestCid = text(mcp?.['manifestCid'], 128);
+ const observedThroughBlock = text(graphRecord?.['blockNumber'], 78);
+ const observedThroughTime = text(graphRecord?.['retrievedAt'], 128);
+
+ const targetState = command['targetState'];
+ const coreReason = text(command['reason']);
+ const details: Partial = {
+ core_disposition: core,
+ settlement_permission: 'NEVER',
+ contradiction: view['contradiction'] === true,
+ contradiction_codes: texts(view['contradictionCodes']),
+ diagnostics: texts(view['diagnostics']),
+ ...(agentRecord &&
+ typeof agentRecord['accepted'] === 'boolean' &&
+ agentReason &&
+ modelName &&
+ modelVersion &&
+ promptVersion
+ ? {
+ agent_decision: {
+ accepted: agentRecord['accepted'],
+ reason: agentReason,
+ model_name: modelName,
+ model_version: modelVersion,
+ prompt_version: promptVersion,
+ evidence_references: texts(agentRecord['evidenceReferences']),
+ },
+ }
+ : {}),
+ ...(coreReason &&
+ (targetState === 'UNKNOWN' || targetState === 'COMMITTED' || targetState === 'FAILED_SAFE')
+ ? {
+ core_decision: {
+ disposition: core,
+ target_state: targetState,
+ reason: coreReason,
+ authoritative_proof_present: command['authoritativeProofPresent'] === true,
+ evidence_references: texts(command['evidenceReferences']),
+ },
+ }
+ : {}),
+ ...(health && serverName && serverVersion && toolName && deploymentId && manifestCid
+ ? {
+ graph_observation: {
+ server_name: serverName,
+ server_version: serverVersion,
+ tool_name: toolName,
+ deployment_id: deploymentId,
+ manifest_cid: manifestCid,
+ ...(observedThroughBlock ? { observed_through_block: observedThroughBlock } : {}),
+ ...(observedThroughTime ? { observed_through_time: observedThroughTime } : {}),
+ health,
+ available: health !== 'UNAVAILABLE',
+ candidate_count: candidates.length,
+ diagnostics: texts(view['diagnostics']),
+ candidates,
+ },
+ }
+ : {}),
+ };
+ return { action, details };
+}
+
interface AttemptRow {
readonly attempt_id: string;
readonly stage: IntentState;
@@ -223,10 +377,24 @@ export class IntentLedger {
async getRecoveryView(idValue: unknown): Promise {
const intent = await this.getIntent(idValue);
if (!intent) return undefined;
+ const latestRecovery = await this.#pool.query<{ payload: unknown }>(
+ `SELECT payload
+ FROM outbox_jobs
+ WHERE business_intent_id = $1
+ AND job_key LIKE 'recovery-event:%'
+ ORDER BY outbox_job_id DESC
+ LIMIT 1`,
+ [intent.business_intent_id],
+ );
+ const persisted = persistedRecovery(latestRecovery.rows[0]?.payload);
return {
business_intent_id: intent.business_intent_id,
authoritative_state: intent.state,
- recommended_action: intent.state === 'COMMITTED' ? 'RETURN_EXISTING_RESULT' : 'WAIT',
+ recommended_action:
+ persisted.action ?? (intent.state === 'COMMITTED' ? 'RETURN_EXISTING_RESULT' : 'WAIT'),
+ recommendation_source: persisted.action ? 'RECOVERY_AGENT' : 'SAFE_FALLBACK',
+ ...persisted.details,
+ settlement_permission: 'NEVER',
evidence: intent.evidence,
};
}
diff --git a/packages/storage-postgres/test/ledger.integration.test.ts b/packages/storage-postgres/test/ledger.integration.test.ts
index eabda29..8a6d26e 100644
--- a/packages/storage-postgres/test/ledger.integration.test.ts
+++ b/packages/storage-postgres/test/ledger.integration.test.ts
@@ -133,4 +133,56 @@ describePostgres('PostgreSQL intent ledger', () => {
await restartedPool.end();
}
});
+
+ it('returns the persisted Recovery Agent and deterministic-core decision', async () => {
+ const ledger = newLedger();
+ await ledger.createOrReplay(request, 'correlation-recovery-view');
+ await ledger.recordRecoveryEvent(request.business_intent_id, 'event-p5', {
+ recoveryView: {
+ recommendedAction: 'ESCALATE',
+ coreDisposition: 'ESCALATE_UNKNOWN',
+ indexedCandidates: [],
+ indexHealth: 'UNAVAILABLE',
+ contradiction: false,
+ contradictionCodes: [],
+ diagnostics: ['MCP_UNAVAILABLE'],
+ },
+ reconciliationCommand: {
+ targetState: 'UNKNOWN',
+ reason: 'No authoritative Arc proof exists.',
+ authoritativeProofPresent: false,
+ evidenceReferences: ['oneshot:state:1'],
+ },
+ appendCommands: [
+ {
+ record: {
+ recordType: 'DECISION',
+ source: 'LLM',
+ accepted: true,
+ reason: 'Escalate because discovery is unavailable.',
+ evidenceReferences: ['oneshot:state:1'],
+ provenance: {
+ modelIdentity: {
+ modelName: 'gemini',
+ modelVersion: '2.5-flash',
+ promptVersion: 'recovery-v1',
+ },
+ },
+ },
+ },
+ ],
+ });
+
+ await expect(ledger.getRecoveryView(request.business_intent_id)).resolves.toMatchObject({
+ recommended_action: 'ESCALATE',
+ recommendation_source: 'RECOVERY_AGENT',
+ core_disposition: 'ESCALATE_UNKNOWN',
+ settlement_permission: 'NEVER',
+ agent_decision: {
+ accepted: true,
+ model_name: 'gemini',
+ model_version: '2.5-flash',
+ },
+ });
+ });
});
diff --git a/plan.md b/plan.md
index 03e14c1..580df97 100644
--- a/plan.md
+++ b/plan.md
@@ -1,6 +1,6 @@
# OneShot Product Delivery Plan
-Status: working testnet MVP; Arc/Privy evidence live; Circle Agent Stack Arc qualification slice planned but not yet implemented; Graph Studio/MCP recovery proof qualified while the decentralized Explorer deployment remains unallocated; P4 PASS; P5/P6 open
+Status: working testnet MVP; Arc/Privy evidence live; Circle Agent Stack Arc qualification slice planned but not yet implemented; Graph Studio/MCP recovery proof qualified while the decentralized Explorer deployment remains unallocated; P4 PASS; P5 candidate under review; P6 open
Team: exactly three coders
Implementation base: the human-approved commit containing this plan
Research basis: `.agent/research/20260906-integration-decisions.md` and `.agent/research/20260907-subgraph-mcp-clarification.md`
diff --git a/plan_missing_parts.md b/plan_missing_parts.md
index 4d5601f..88d4095 100644
--- a/plan_missing_parts.md
+++ b/plan_missing_parts.md
@@ -75,17 +75,13 @@ All backend composition pieces, the live Privy/Arc allowed, denied, and lost-res
### Gate P5 frontend acceptance
-The intent/status UI and a synthetic recovery viewer are implemented, but the
-repository status still marks live API wiring as pending. Complete the frozen
-API composition and browser-level acceptance coverage for:
-
-- create, replay, and conflicting intent payloads;
-- policy denial, committed settlement, and `UNKNOWN` recovery states;
-- Graph discovery, lag/error, and multiple-candidate states; and
-- accessibility, responsive layout, no-secret, and no-force-pay checks.
-
-The plan calls for Playwright browser flows; the current workspace evidence is
-primarily Vitest component/client tests and fixture-backed recovery UI tests.
+The Gate P5 candidate composes A05/B05/C05 against the frozen API and adds
+Playwright coverage for create, replay, conflict, denial, committed, `UNKNOWN`,
+Graph discovery/degradation, service-unavailable, keyboard, responsive,
+memory-only token, and no-force-pay behavior. The recovery API now returns the
+persisted Recovery Agent and deterministic-core decision instead of a hard-coded
+action. Exact-tree review, CI, and human merge remain before the project gate is
+closed.
### Gate P6 release candidate
@@ -100,16 +96,15 @@ and the exact release candidate completes CI plus Gate A and Gate B review.
| --- | --- | --- |
| Live Graph recovery | RESOLVED: Live Subgraph Studio deployment, MCP client, and Vertex AI Gemini adapter operational (`QUALIFIED`) | Preserved `settlementPermission: NEVER`. |
| P4 live lost-hash proof | RESOLVED: Full live lost-hash recovery trace verified and recorded | Gate P4 is PASS. |
-| P5 live UI acceptance | Reachable configured API, safe test data, and browser-test environment | Continue fixture/mock coverage; do not add a payment bypass. |
+| P5 live UI acceptance | RESOLVED in candidate: configured Cloud Run is reachable and frozen-API Playwright coverage exists | Await exact-tree review, CI, and human merge. |
| P6 release | P5 completion, CI, exact-tree reviews, and human demo/submission decisions | Keep release candidate and sponsor claims incomplete. |
| Arc Mainnet | Official published network values and explicit human authorization | Preserve the disabled, fail-closed profile. |
| Circle Agent Stack Arc lane | Agent Stack/Agent Wallet implementation, supported-chain confirmation, spend controls, live payment, and evidence | Keep the Circle Arc claim `NOT VERIFIED`; continue the proven Privy/Arc path until the complete acceptance checklist passes. |
## Immediate Priorities
-1. Implement and test the bounded Circle Agent Stack Arc service-payment lane;
+1. Complete Gate A, CI, Gate B, and human review for the Gate P5 candidate.
+2. Implement and test the bounded Circle Agent Stack Arc service-payment lane;
keep Privy as the canonical OneShot settlement authority.
-2. Wire the operator UI to the configured API and add the P5 browser acceptance
- suite, especially `UNKNOWN` and Graph-degraded recovery views.
3. After P5 and the Circle evidence pass, capture the demo/submission artifacts
and perform the P6 release-candidate CI and review sequence.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aea0324..75a768b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -70,6 +70,12 @@ importers:
'@oneshot/contracts':
specifier: workspace:*
version: link:../../packages/contracts
+ '@oneshot/recovery-ui':
+ specifier: workspace:*
+ version: link:../../packages/recovery-ui
+ '@oneshot/settlement-ui':
+ specifier: workspace:*
+ version: link:../../packages/settlement-ui
react:
specifier: 19.2.8
version: 19.2.8
@@ -77,6 +83,9 @@ importers:
specifier: 19.2.8
version: 19.2.8(react@19.2.8)
devDependencies:
+ '@playwright/test':
+ specifier: 1.63.0
+ version: 1.63.0
'@testing-library/react':
specifier: 16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -1184,6 +1193,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.63.0':
+ resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
'@pnpm/config.env-replace@1.1.0':
resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==}
engines: {node: '>=12.22.0'}
@@ -3307,6 +3321,16 @@ packages:
resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==}
hasBin: true
+ playwright-core@1.63.0:
+ resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ playwright@1.63.0:
+ resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ engines: {node: '>=20'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -5006,6 +5030,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.63.0':
+ dependencies:
+ playwright: 1.63.0
+
'@pnpm/config.env-replace@1.1.0': {}
'@pnpm/network.ca-file@1.0.2':
@@ -7237,6 +7265,12 @@ snapshots:
sonic-boom: 4.2.1
thread-stream: 4.2.0
+ playwright-core@1.63.0: {}
+
+ playwright@1.63.0:
+ dependencies:
+ playwright-core: 1.63.0
+
pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
diff --git a/vitest.config.ts b/vitest.config.ts
index e31cc5a..9b2efde 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -3,7 +3,12 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: { enabled: false },
- exclude: ['**/*.integration.test.ts', '**/node_modules/**', '**/dist/**'],
+ exclude: [
+ '**/*.integration.test.ts',
+ 'apps/web/test/**/*.spec.ts',
+ '**/node_modules/**',
+ '**/dist/**',
+ ],
include: ['{apps,packages}/**/*.{test,spec}.{ts,mjs}'],
},
});