Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions .agent/context/20260913T050212Z-always-on-graph-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Session Context: always-on-graph-evidence

## Date/time

- UTC: 2026-09-13T05:02:12Z

## User goal

Make The Graph evidence appear for every confirmed transaction, then commit,
push, and open a draft PR. The PR must state that Gate A was not started.

## Original prompt/request

"right know we use the graph only if payment failed or something got wrong. We
want that the graph evidence will apear always, on every transaction. Implement
this feature. Also make commit and push after you finished. And alos make a
draft pr without starting gate A, but write in pr msg that GATE a wasn't
started"

## Assumptions

- “Every transaction” means every confirmed OneShot settlement, including
server-wallet and user-wallet commits; failed-safe attempts are not
transactions.
- Graph evidence remains non-authoritative and cannot change settlement or
retry permission.
- A durable outbox task is preferable to an inline post-commit call so a worker
restart cannot permanently lose the evidence capture.
- The existing modified `.agent/context/20260912T-user-wallet-payment.md` is
unrelated user work and must remain unstaged.

## Plan

1. Add an idempotent post-commit Graph evidence outbox task and worker port.
2. Wire production recovery configuration to capture Graph observations and
record `UNAVAILABLE` evidence on Graph boundary failure.
3. Add focused worker/recovery coverage and update migration/integration
expectations.
4. Run local checks, inspect the exact staged tree, commit, push, and create a
draft PR without starting Gate A.

## Key decisions

- Use a separate `capture_graph_evidence` task instead of running the recovery
LLM for successful payments. This keeps normal execution read-only and
avoids turning evidence capture into a retry/reconciliation decision.
- Make `(business_intent_id, source, digest)` unique and use `ON CONFLICT DO
NOTHING`, so redelivery after a crash does not duplicate evidence.
- Query the durable payer wallet for user-wallet jobs when constructing the
Graph correlation request; server-wallet intents retain the configured
sender fallback.

## Files/components touched

- `packages/storage-postgres/migrations/012_graph_evidence.sql`
- `packages/storage-postgres/src/ledger.ts`
- `apps/worker/src/types.ts`
- `apps/worker/src/recovery-bridge.ts`
- `apps/worker/src/composition.ts`
- `apps/worker/src/worker.ts`
- `apps/worker/README.md` and `apps/worker/FAILURE_CATALOG.md`
- focused worker tests and storage integration expectations

## Commands/checks

- Repository policy and routed documents read: `.agent/AGENTS.md`, project
context, security invariants, sponsor requirements, test matrix,
implementation loop, and `oneshot-idempotency/SKILL.md`.
- Branch created from current `develop`: `feature/always-on-graph-evidence`.
- `pnpm.cmd --filter @oneshot/worker test` - 7 files / 50 tests passed.
- `pnpm.cmd --filter @oneshot/reconciliation test` - 8 files / 89 tests passed.
- `pnpm.cmd --filter @oneshot/storage-postgres test` - 4 files / 15 tests passed
(PostgreSQL-gated tests skipped without a container runtime).
- `pnpm.cmd --filter @oneshot/settlement-ui test` - 5 files / 211 tests passed.
- `pnpm.cmd test` - 80 files / 1,055 tests passed.
- Worker, reconciliation, and storage typechecks plus root lint/build passed.
- `git diff --check` passed; targeted Prettier checks passed after formatting.

## External-doc findings

- Repository policy defines OneShot as authoritative for settlement and The
Graph as non-authoritative candidate discovery; missing/delayed index data
cannot authorize payment.
- Test matrix requires Graph boundary failures to fail closed and durable
evidence metadata to be retained.

## Unresolved questions

- None; Graph evidence capture may be unavailable, but the observation must
still be recorded with `UNAVAILABLE` freshness.

## Git and PR state

- Branch: `feature/always-on-graph-evidence`
- Base: `develop` at `62920523c5a323cfc0e38d57c632f498b4d921d5`
- Feature commit: `4fea7e66d6187760052d24508d47e73fa2b8daca`
- Feature tree: `86f39915dbab92450483c367f9fc0df67bae6172`
- Remote branch: pushed to `origin/feature/always-on-graph-evidence`
- Draft PR: [#124](https://github.com/SWOFART/OneShot/pull/124)
- PR head at creation: feature commit/tree above
- CI: GitHub checks are pending/queued; local validation passed

## Review gates

- Gate A: NOT RUN (explicitly requested to skip)
- Gate B: NOT RUN

## Handoff/next steps

1. Keep the unrelated `.agent/context/20260912T-user-wallet-payment.md`
modification unstaged.
2. Human review and CI follow-up remain; do not start Gate A or Gate B.
1 change: 1 addition & 0 deletions apps/worker/FAILURE_CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This catalog documents the external-boundary failure points, expected state tran
| **FP-03: Provider error / timeout** | Port throws network exception or timeout | `SUBMITTING` | 1 | Transition to `UNKNOWN`, persist sanitized error, enqueue reconciliation | No retry without proof |
| **FP-04: Definitive rejection** | Port returns `DEFINITELY_NOT_SUBMITTED` | `SUBMITTING` | 1 | Transition to `FAILED_SAFE`, persist failure reason; a policy layer may schedule a fresh authorization attempt | No retry without authoritative no-effect proof |
| **FP-05: Downstream failure after commit** | Failure after `COMMITTED` state and settlement persisted | `COMMITTED` | 1 | Settlement remains permanently recorded; no replacement payment | Settlement identity is immutable |
| **FP-05a: Graph evidence read failure** | Graph lookup fails after the committed settlement transaction | `COMMITTED` | 1 | Durable evidence task records `THE_GRAPH` with `UNAVAILABLE`; settlement remains committed | Index evidence never controls payment |
| **FP-06: 10 Parallel workers storm** | 10 workers race on same `READY` intent | `READY` | 1 (winner only) | Exactly 1 worker wins CAS to `SUBMITTING`; 9 workers exit without calling port | Exactly 1 committed settlement |
| **FP-07: 10 Sequential deliveries** | Same intent job delivered 10 times in sequence | `AUTHORIZING` $\rightarrow$ `COMMITTED` | 1 | First delivery commits settlement; subsequent deliveries find `COMMITTED` and exit | At most 1 settlement |

Expand Down
4 changes: 4 additions & 0 deletions apps/worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Atomic at-most-once execution worker for OneShot Business Intents.
- `authorize_intent`: Validates intent against corporate spending and policy rules, advancing state to `READY` (or `REJECTED`).
- `submit_settlement`: Atomically claims submission right and executes settlement via configured settlement port.
- `reconcile_intent`: Reconciles ambiguous intent state against evidence observations.
- `capture_graph_evidence`: Reads the pinned Graph source after a confirmed
settlement and appends a non-authoritative observation. Graph failure is
recorded as `UNAVAILABLE`; it never changes settlement state or grants a
retry.

## Architecture and Dispatch

Expand Down
15 changes: 15 additions & 0 deletions apps/worker/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { TeamReportSupplier } from '@oneshot/supplier-adapter';
import type { Pool } from 'pg';
import type {
AuthorizationPort,
GraphEvidenceCapturePort,
SettlementContext,
SettlementPort,
WorkerOptions,
Expand All @@ -27,6 +28,7 @@ import {
IntentLedgerLocalRecoveryStatePort,
IntentLedgerRecoveryCommandStore,
PrivyArcEvidenceBridge,
IntentLedgerGraphEvidenceCapturePort,
type IntentLedgerLocalRecoveryStatePortOptions,
type PrivyArcEvidenceBridgeOptions,
} from './recovery-bridge.js';
Expand Down Expand Up @@ -121,6 +123,7 @@ export interface CompositionOptions {
readonly contractVersion?: string;
};
readonly recoveryService?: RecoveryService;
readonly graphEvidence?: GraphEvidenceCapturePort;
readonly recovery?: ProductionRecoveryServiceOptions;
readonly submissionsDisabled?: boolean;
readonly expectedContractVersion?: string;
Expand Down Expand Up @@ -164,19 +167,31 @@ export function composeWorker(
}

let recoveryService = options.recoveryService;
let graphEvidence = options.graphEvidence;
if (!recoveryService && options.profile === 'production' && options.recovery) {
recoveryService = createProductionRecoveryService(ledger, options.recovery);
}
if (!graphEvidence && options.profile === 'production' && options.recovery) {
const localState = new IntentLedgerLocalRecoveryStatePort(ledger, options.recovery.localState);
graphEvidence = new IntentLedgerGraphEvidenceCapturePort(
localState,
options.recovery.subgraphMcp,
);
}
if (options.profile === 'production' && !recoveryService) {
throw new Error('Production composition profile requires an injected recoveryService');
}
if (options.profile === 'production' && !graphEvidence) {
throw new Error('Production composition profile requires an injected graphEvidence port');
}

const workerOptions: WorkerOptions = {
pool,
ledger,
settlementPort,
authorizationPort,
recoveryService,
graphEvidence,
jobLedger: new JobLedger(pool, { now: () => new Date(), nextAttemptId: randomUUID }),
supplier: options.supplier ?? new TeamReportSupplier(),
config: {
Expand Down
70 changes: 69 additions & 1 deletion apps/worker/src/recovery-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type RecoveryCommandPack,
type RecoveryCommandStorePort,
type RecoveryCommandStoreResult,
type SubgraphMcpRecoveryPort,
type SubgraphMcpPolicy,
} from '@oneshot/reconciliation';
import {
Expand All @@ -30,6 +31,7 @@ import {
type TransactionReceipt,
} from '@oneshot/arc-adapter';
import type { EvidencePort as LaneBEvidencePort } from '@oneshot/privy-adapter';
import type { GraphEvidenceCapturePort, GraphEvidenceCaptureRequest } from './types.js';
import { createHash } from 'node:crypto';

function sha256Hex(value: string): string {
Expand Down Expand Up @@ -92,6 +94,12 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor

const durableState = mapState(intent.state);
const nowIso = new Date().toISOString();
const ledgerWithPayer = this.ledger as IntentLedger & {
getPaymentPayerWallet?: (businessIntentId: string) => Promise<string | undefined>;
};
const payerWallet = ledgerWithPayer.getPaymentPayerWallet
? await ledgerWithPayer.getPaymentPayerWallet(businessIntentId)
: undefined;
const toBlock = this.options.getToBlock
? await this.options.getToBlock()
: this.options.toBlock;
Expand All @@ -104,7 +112,7 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor
binding,
correlation: {
strategy: 'TRANSFER_TUPLE_WINDOW',
sender: this.options.correlationSender,
sender: payerWallet ?? this.options.correlationSender,
fromBlock: this.options.fromBlock,
toBlock,
},
Expand Down Expand Up @@ -146,6 +154,66 @@ export class IntentLedgerLocalRecoveryStatePort implements LocalRecoveryStatePor
}
}

/**
* Captures non-authoritative Graph evidence for a confirmed settlement. This
* port never reads or writes settlement authority; it only produces a bounded
* observation for the durable evidence timeline.
*/
export class IntentLedgerGraphEvidenceCapturePort implements GraphEvidenceCapturePort {
constructor(
private readonly localState: LocalRecoveryStatePort,
private readonly subgraphMcp: SubgraphMcpRecoveryPort,
private readonly now: () => string = () => new Date().toISOString(),
) {}

async capture(request: GraphEvidenceCaptureRequest): Promise<EvidenceView> {
const retrievedAt = this.now();
try {
const snapshot = await this.localState.read(request.businessIntentId);
const outcome = await this.subgraphMcp.lookup(snapshot.indexRequest, snapshot.mcpPolicy);
const view = outcome.view;
const digest = sha256Hex(
JSON.stringify({
businessIntentId: request.businessIntentId,
transactionHash: request.transactionHash,
blockNumber: request.blockNumber,
graph: view.graph,
observedThrough: view.observedThrough,
health: view.health,
candidates: view.candidates.map((candidate) => ({
id: candidate.id,
transactionHash: candidate.transactionHash,
logIndex: candidate.logIndex,
blockNumber: candidate.blockNumber,
bindingStatus: candidate.bindingStatus,
})),
diagnostics: view.diagnostics,
}),
);
return {
source: 'THE_GRAPH',
authority_class: 'OBSERVATION',
retrieved_at: view.retrievedAt,
digest: `graph-capture:${digest}`,
...(view.observedThrough?.blockNumber
? { block_number: view.observedThrough.blockNumber }
: {}),
freshness: view.health,
};
} catch {
return {
source: 'THE_GRAPH',
authority_class: 'OBSERVATION',
retrieved_at: retrievedAt,
digest: `graph-capture:${sha256Hex(
`graph-unavailable:${request.businessIntentId}:${request.transactionHash}:${request.blockNumber}`,
)}`,
freshness: 'UNAVAILABLE',
};
}
}
}

interface DurableLedgerExtension {
recordRecoveryEvent?: (
businessIntentId: string,
Expand Down
12 changes: 12 additions & 0 deletions apps/worker/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AuthorizationResult,
CreateIntentRequest,
EvidenceView,
SettlementResult,
} from '@oneshot/contracts';
import type { IntentLedger } from '@oneshot/storage-postgres';
Expand Down Expand Up @@ -34,6 +35,16 @@ export interface SettlementPort {
submit(request: CreateIntentRequest, context: SettlementContext): Promise<SettlementResult>;
}

export interface GraphEvidenceCaptureRequest {
readonly businessIntentId: string;
readonly transactionHash: string;
readonly blockNumber: string;
}

export interface GraphEvidenceCapturePort {
capture(request: GraphEvidenceCaptureRequest): Promise<EvidenceView>;
}

export interface WorkerConfig {
readonly submissionsDisabled?: boolean | undefined;
readonly authorizationRetryDelayMs?: number | undefined;
Expand All @@ -48,6 +59,7 @@ export interface WorkerOptions {
readonly authorizationPort?: AuthorizationPort | undefined;
readonly settlementPort: SettlementPort;
readonly recoveryService?: RecoveryService | undefined;
readonly graphEvidence?: GraphEvidenceCapturePort | undefined;
readonly jobLedger?: JobLedger | undefined;
readonly supplier?: SupplierPort | undefined;
readonly concurrency?: number | undefined;
Expand Down
Loading
Loading